chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
/**
* EmbeddedWebview - 内嵌 webview 浏览器组件
*
* 提供工具栏(返回/刷新)+ Electron <webview> + 加载失败提示。
* 使用 defaultSession与 session:setCookie 设置的 cookie 共享。
*/
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Button, Alert } from "antd";
import { ArrowLeftOutlined, ReloadOutlined } from "@ant-design/icons";
import { APP_DISPLAY_NAME } from "@shared/constants";
import { t } from "../../services/core/i18n";
import styles from "../styles/components/EmbeddedWebview.module.css";
interface EmbeddedWebviewProps {
/** 要加载的 URL */
url: string;
/** 点击「返回」按钮回调 */
onClose: () => void;
}
function EmbeddedWebview({ url, onClose }: EmbeddedWebviewProps) {
const [error, setError] = useState<string | null>(null);
const [userAgent, setUserAgent] = useState<string | undefined>();
const webviewRef = useRef<HTMLElement | null>(null);
useEffect(() => {
window.electronAPI?.app
.getVersion()
.then((version) => {
setUserAgent(navigator.userAgent + ` ${APP_DISPLAY_NAME}/${version}`);
})
.catch(() => {});
}, []);
useEffect(() => {
const el = webviewRef.current as any;
if (!el) return;
const onFailLoad = (e: any) => {
// errorCode -3 is "aborted" (navigation cancelled), not a real error
if (e.errorCode && e.errorCode !== -3) {
setError(
t(
"Claw.EmbeddedWebview.loadFailed",
e.errorDescription || t("Claw.EmbeddedWebview.unknownError"),
String(e.errorCode),
),
);
}
};
const onStartLoading = () => setError(null);
el.addEventListener("did-fail-load", onFailLoad);
el.addEventListener("did-start-loading", onStartLoading);
return () => {
el.removeEventListener("did-fail-load", onFailLoad);
el.removeEventListener("did-start-loading", onStartLoading);
};
}, []);
const handleReload = useCallback(() => {
(webviewRef.current as any)?.reload?.();
}, []);
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<Button size="small" icon={<ArrowLeftOutlined />} onClick={onClose}>
{t("Claw.EmbeddedWebview.back")}
</Button>
<span className={styles.url}>{url}</span>
<Button size="small" icon={<ReloadOutlined />} onClick={handleReload}>
{t("Claw.EmbeddedWebview.refresh")}
</Button>
</div>
{error && (
<Alert
message={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
style={{ margin: "8px 12px 0", flexShrink: 0 }}
/>
)}
<webview
ref={webviewRef as any}
src={url}
useragent={userAgent}
style={{ flex: 1, width: "100%", border: "none" }}
allowpopups={"true" as any}
/>
</div>
);
}
export default EmbeddedWebview;

View File

@@ -0,0 +1,364 @@
/**
* 开发工具面板
*
* 仅在开发模式下加载,提供:
* - 重置初始化状态(重新显示设置向导)
* - 清除登录状态
* - 清除全部数据并刷新
* - 查看应用存储数据
* - SetupDependencies UI 测试
*
* 注意:本组件为纯调试工具,不接入 i18n所有 UI 文案硬编码英文。
*/
import { useState } from "react";
import { Button, Modal, Tag, message } from "antd";
import {
ReloadOutlined,
DeleteOutlined,
ClearOutlined,
DatabaseOutlined,
ExperimentOutlined,
} from "@ant-design/icons";
import { setupService } from "../../services/core/setup";
import SetupDependenciesTest from "./SetupDependenciesTest";
import SetupWizardTest from "./SetupWizardTest";
export default function DevToolsPanel() {
const [storeData, setStoreData] = useState<Record<string, unknown> | null>(
null,
);
const [storeModalVisible, setStoreModalVisible] = useState(false);
const [setupDepsTestVisible, setSetupDepsTestVisible] = useState(false);
const [setupWizardTestVisible, setSetupWizardTestVisible] = useState(false);
// 重置初始化
const handleResetSetup = async () => {
try {
await setupService.resetSetup();
message.success(
"Initialization state reset. Setup wizard will show after refresh.",
);
} catch {
message.error("Reset failed");
}
};
// 清除登录
const handleClearAuth = async () => {
try {
await window.electronAPI?.settings.set("auth.username", null);
await window.electronAPI?.settings.set("auth.password", null);
await window.electronAPI?.settings.set("auth.config_key", null);
await window.electronAPI?.settings.set("auth.user_info", null);
await window.electronAPI?.settings.set("auth.online_status", null);
message.success("Login state cleared");
} catch {
message.error("Clear failed");
}
};
// 清除全部并刷新
const handleClearAll = () => {
Modal.confirm({
title: "Clear All Data",
content:
"This will reset initialization state and login info. Page will auto refresh. Continue?",
okText: "Clear & Reload",
okType: "danger",
cancelText: "Cancel",
onOk: async () => {
try {
await setupService.resetSetup();
await window.electronAPI?.settings.set("auth.username", null);
await window.electronAPI?.settings.set("auth.password", null);
await window.electronAPI?.settings.set("auth.config_key", null);
await window.electronAPI?.settings.set("auth.user_info", null);
await window.electronAPI?.settings.set("auth.online_status", null);
message.success("All data cleared, reloading...");
setTimeout(() => window.location.reload(), 500);
} catch {
message.error("Clear failed");
}
},
});
};
// 查看存储数据
const handleViewStore = async () => {
try {
const keys = [
"setup_state",
"step1_config",
"anthropic_api_key",
"app_settings",
"agent_config",
"mcp_config",
"lanproxy_config",
"auth.username",
"auth.password",
"auth.config_key",
"auth.saved_key",
"auth.user_info",
"auth.online_status",
"lanproxy.server_host",
"lanproxy.server_port",
];
const data: Record<string, unknown> = {};
for (const key of keys) {
const value = await window.electronAPI?.settings.get(key);
if (value !== null && value !== undefined) {
// 敏感字段脱敏
if (key === "auth.password" || key === "anthropic_api_key") {
data[key] = "******";
} else {
data[key] = value;
}
}
}
setStoreData(data);
setStoreModalVisible(true);
} catch {
message.error("Failed to read store data");
}
};
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: 10,
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: "#18181b" }}>
{"Developer Tools"}
</span>
<Tag color="orange" style={{ margin: 0, fontSize: 10 }}>
DEV
</Tag>
</div>
<div
style={{
border: "1px solid #e4e4e7",
borderRadius: 8,
background: "#fff",
}}
>
{/* 重置初始化 */}
<div style={rowStyle}>
<div>
<div style={{ fontSize: 13, color: "#18181b" }}>
{"Reset Initialization"}
</div>
<div style={{ fontSize: 11, color: "#a1a1aa", marginTop: 1 }}>
{
"Clear setup wizard completion flag, wizard will show after refresh"
}
</div>
</div>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={handleResetSetup}
>
{"Reset"}
</Button>
</div>
{/* 清除登录 */}
<div style={rowStyle}>
<div>
<div style={{ fontSize: 13, color: "#18181b" }}>
{"Clear Login"}
</div>
<div style={{ fontSize: 11, color: "#a1a1aa", marginTop: 1 }}>
{"Clear username, password, ConfigKey (keep SavedKey)"}
</div>
</div>
<Button
size="small"
icon={<ClearOutlined />}
onClick={handleClearAuth}
>
{"Clear"}
</Button>
</div>
{/* 清除全部并刷新 */}
<div style={rowStyle}>
<div>
<div style={{ fontSize: 13, color: "#18181b" }}>
{"Clear All & Reload"}
</div>
<div style={{ fontSize: 11, color: "#a1a1aa", marginTop: 1 }}>
{"Reset init + clear login, auto refresh page"}
</div>
</div>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={handleClearAll}
>
{"Clear All"}
</Button>
</div>
{/* 查看存储数据 */}
<div style={{ ...rowStyle, borderBottom: "none" }}>
<div>
<div style={{ fontSize: 13, color: "#18181b" }}>{"Store Data"}</div>
<div style={{ fontSize: 11, color: "#a1a1aa", marginTop: 1 }}>
{"View key-value data in SQLite (sensitive fields masked)"}
</div>
</div>
<Button
size="small"
icon={<DatabaseOutlined />}
onClick={handleViewStore}
>
{"View"}
</Button>
</div>
</div>
{/* 存储数据弹窗 */}
<Modal
title={"Store Data"}
open={storeModalVisible}
onCancel={() => setStoreModalVisible(false)}
footer={null}
width={520}
>
{storeData && (
<pre
style={{
fontSize: 11,
background: "#f5f5f5",
padding: 12,
borderRadius: 6,
maxHeight: 400,
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{JSON.stringify(storeData, null, 2)}
</pre>
)}
</Modal>
{/* SetupDependencies UI 测试入口 */}
<div style={{ marginTop: 16 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: 10,
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: "#18181b" }}>
{"UI Test"}
</span>
<Tag color="purple" style={{ margin: 0, fontSize: 10 }}>
TEST
</Tag>
</div>
<div
style={{
border: "1px solid #e4e4e7",
borderRadius: 8,
background: "#fff",
overflow: "hidden",
}}
>
<div style={{ ...rowStyle }}>
<div>
<div style={{ fontSize: 13, color: "#18181b" }}>
{"Setup Dependencies Wizard"}
</div>
<div style={{ fontSize: 11, color: "#a1a1aa", marginTop: 1 }}>
{"Test SetupDependencies UI at various phases"}
</div>
</div>
<Button
size="small"
icon={<ExperimentOutlined />}
onClick={() => setSetupDepsTestVisible(true)}
>
{"Test"}
</Button>
</div>
<div style={{ ...rowStyle, borderBottom: "none" }}>
<div>
<div style={{ fontSize: 13, color: "#18181b" }}>
{"Full Initialization Flow"}
</div>
<div style={{ fontSize: 11, color: "#a1a1aa", marginTop: 1 }}>
{"Test full flow: deps install + config + login"}
</div>
</div>
<Button
size="small"
type="primary"
icon={<ExperimentOutlined />}
onClick={() => setSetupWizardTestVisible(true)}
>
{"Test"}
</Button>
</div>
</div>
</div>
{/* SetupDependencies 测试弹窗 */}
<Modal
title={"SetupDependencies UI Test"}
open={setupDepsTestVisible}
onCancel={() => setSetupDepsTestVisible(false)}
footer={null}
width={680}
styles={{
body: {
maxHeight: "70vh",
overflow: "auto",
padding: 0,
},
}}
>
<SetupDependenciesTest />
</Modal>
{/* SetupWizardTest 测试弹窗 */}
<Modal
title={"Setup Wizard Full Flow Test"}
open={setupWizardTestVisible}
onCancel={() => setSetupWizardTestVisible(false)}
footer={null}
width={800}
styles={{
body: {
maxHeight: "80vh",
overflow: "auto",
padding: 0,
},
}}
>
<SetupWizardTest />
</Modal>
</div>
);
}
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 14px",
borderBottom: "1px solid #f4f4f5",
};

View File

@@ -0,0 +1,299 @@
/**
* SetupDependencies UI 测试页面
*
* 仅开发环境使用,用于测试初始化安装向导的各个阶段 UI 效果
* 通过 mockApi props 注入 Mock 数据,不修改全局对象
*/
import { useState, useEffect, useRef, useMemo } from "react";
import { Button, Select, Space, Card, Divider } from "antd";
import SetupDependencies, {
type InstallPhase,
type DisplayDependencyItem,
type MockDependenciesApi,
} from "../setup/SetupDependencies";
// ==================== Mock 预设 ====================
const MOCK_PRESETS: Record<
string,
{ phase: InstallPhase; deps: DisplayDependencyItem[] }
> = {
checking: {
phase: "checking",
deps: [],
},
systemMissing: {
phase: "system-deps-missing",
deps: [
{
name: "uv",
displayName: "uv",
type: "system",
description: "Python 包管理器",
status: "missing",
requiredVersion: ">= 0.5.0",
installUrl: "https://docs.astral.sh/uv/getting-started/installation/",
},
],
},
installing: {
phase: "installing",
deps: [
{
name: "uv",
displayName: "uv",
type: "system",
description: "Python 包管理器",
status: "installed",
version: "0.5.0",
},
{
name: "claude-code-acp-ts",
displayName: "Claude Code ACP",
type: "npm-local",
description: "Claude Code ACP 协议实现",
status: "installing",
},
{
name: "qimingcode",
displayName: "QimingCode",
type: "npm-local",
description: "QimingCode ACP 协议实现",
status: "missing",
},
{
name: "qiming-file-server",
displayName: "File Server",
type: "npm-local",
description: "本地文件服务",
status: "missing",
},
{
name: "qiming-mcp-stdio-proxy",
displayName: "MCP Proxy",
type: "npm-local",
description: "MCP 协议聚合代理",
status: "missing",
},
],
},
completed: {
phase: "completed",
deps: [
{
name: "uv",
displayName: "uv",
type: "system",
description: "Python 包管理器",
status: "installed",
version: "0.5.0",
},
{
name: "claude-code-acp-ts",
displayName: "Claude Code ACP",
type: "npm-local",
description: "Claude Code ACP 协议实现",
status: "installed",
version: "1.0.0",
},
{
name: "qimingcode",
displayName: "QimingCode",
type: "npm-local",
description: "QimingCode ACP 协议实现",
status: "installed",
version: "1.0.0",
},
{
name: "qiming-file-server",
displayName: "File Server",
type: "npm-local",
description: "本地文件服务",
status: "installed",
version: "1.0.0",
},
{
name: "qiming-mcp-stdio-proxy",
displayName: "MCP Proxy",
type: "npm-local",
description: "MCP 协议聚合代理",
status: "installed",
version: "1.0.0",
},
],
},
error: {
phase: "error",
deps: [
{
name: "uv",
displayName: "uv",
type: "system",
description: "Python 包管理器",
status: "installed",
version: "0.5.0",
},
{
name: "claude-code-acp-ts",
displayName: "Claude Code ACP",
type: "npm-local",
description: "Claude Code ACP 协议实现",
status: "installed",
version: "1.0.0",
},
{
name: "qimingcode",
displayName: "QimingCode",
type: "npm-local",
description: "QimingCode ACP 协议实现",
status: "error",
errorMessage: "网络超时,安装失败",
},
{
name: "qiming-file-server",
displayName: "File Server",
type: "npm-local",
description: "本地文件服务",
status: "missing",
},
{
name: "qiming-mcp-stdio-proxy",
displayName: "MCP Proxy",
type: "npm-local",
description: "MCP 协议聚合代理",
status: "missing",
},
],
},
};
export default function SetupDependenciesTest() {
const [preset, setPreset] = useState<string>("installing");
const [autoPlay, setAutoPlay] = useState(false);
const [key, setKey] = useState(0); // 用于强制重新挂载组件
const autoPlayRef = useRef<NodeJS.Timeout | null>(null);
const presetOptions = [
{ label: "检测中", value: "checking" },
{ label: "系统依赖缺失", value: "systemMissing" },
{ label: "安装中", value: "installing" },
{ label: "安装完成", value: "completed" },
{ label: "安装失败", value: "error" },
];
// 创建 mock API使用 useMemo 避免不必要的重建)
const mockApi: MockDependenciesApi = useMemo(
() => ({
checkAll: async () => {
const presetData = MOCK_PRESETS[preset];
return {
success: true,
results:
presetData?.deps.map((d) => ({
name: d.name,
displayName: d.displayName,
type: d.type,
description: d.description,
status: d.status,
version: d.version,
minVersion: d.requiredVersion?.replace(">= ", ""),
errorMessage: d.errorMessage,
installVersion: d.installVersion,
})) || [],
};
},
checkUv: async () => {
const presetData = MOCK_PRESETS[preset];
const uv = presetData?.deps.find((d) => d.name === "uv");
return {
success: true,
installed: uv?.status === "installed",
version: uv?.version,
bundled: true,
};
},
installPackage: async (name: string, options?: { version?: string }) => {
await new Promise((r) => setTimeout(r, 800 + Math.random() * 500));
if (name === "qimingcode" && Math.random() > 0.7) {
return { success: false, error: "网络超时,安装失败" };
}
return { success: true, version: options?.version || "1.0.0" };
},
openExternal: async () => {},
}),
[preset],
);
// 自动播放
useEffect(() => {
if (autoPlay) {
const phases = ["checking", "installing", "completed"];
let index = phases.indexOf(preset);
autoPlayRef.current = setInterval(() => {
index = (index + 1) % phases.length;
setPreset(phases[index]);
setKey((k) => k + 1); // 强制重新挂载组件
}, 2500);
return () => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
};
}
}, [autoPlay, preset]);
const currentPhase = MOCK_PRESETS[preset]?.phase;
return (
<div style={{ padding: 24, maxWidth: 600, margin: "0 auto" }}>
<Card title="SetupDependencies UI 测试" size="small">
<Space wrap>
<span style={{ fontSize: 12 }}></span>
<Select
size="small"
value={preset}
onChange={(value) => {
setPreset(value);
setKey((k) => k + 1); // 切换时重新挂载
}}
options={presetOptions}
style={{ width: 160 }}
disabled={autoPlay}
/>
<Button
size="small"
type={autoPlay ? "primary" : "default"}
onClick={() => setAutoPlay(!autoPlay)}
>
{autoPlay ? "停止" : "自动播放"}
</Button>
</Space>
<div
style={{
fontSize: 11,
color: "var(--color-text-tertiary)",
marginTop: 8,
}}
>
: <b>{currentPhase}</b>
</div>
</Card>
<Divider style={{ margin: "16px 0" }} />
{/* 被测组件 - 使用 key 强制重新挂载 */}
<div
style={{
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: 16,
background: "var(--color-bg-container)",
}}
>
<SetupDependencies key={key} mockApi={mockApi} onComplete={() => {}} />
</div>
</div>
);
}

View File

@@ -0,0 +1,370 @@
/**
* 初始化向导完整流程测试页面
*
* 测试覆盖:
* 1. 依赖检测/安装进度
* 2. 基础配置(端口、工作区目录)
* 3. 账号登录
*
* 通过 Mock API 模拟各阶段效果,无需真实环境
*/
import { useState, useMemo } from "react";
import {
Button,
Select,
Space,
Card,
Divider,
Radio,
Switch,
message,
} from "antd";
import SetupWizard from "../setup/SetupWizard";
import type { MockDependenciesApi } from "../setup/SetupDependencies";
import { t } from "../../services/core/i18n";
// ==================== 测试场景预设 ====================
type TestScenario =
| "full-install"
| "deps-ready"
| "already-logged-in"
| "error-flow";
const SCENARIO_CONFIG: Record<
TestScenario,
{
label: string;
description: string;
depsPhase:
| "checking"
| "installing"
| "completed"
| "error"
| "system-deps-missing";
}
> = {
"full-install": {
label: "完整安装流程",
description: "从依赖检测到安装完成的全流程",
depsPhase: "installing",
},
"deps-ready": {
label: "依赖已就绪",
description: "跳过依赖安装,直接进入配置",
depsPhase: "completed",
},
"already-logged-in": {
label: "已登录状态",
description: "显示已登录的快捷流程",
depsPhase: "completed",
},
"error-flow": {
label: "安装失败",
description: "模拟安装失败场景",
depsPhase: "error",
},
};
// ==================== Mock 数据 ====================
const MOCK_DEPS_INSTALLING = [
{
name: "uv",
displayName: "uv",
type: "system" as const,
description: "Python 包管理器",
status: "installed" as const,
version: "0.5.0",
},
{
name: "claude-code-acp-ts",
displayName: "Claude Code ACP",
type: "npm-local" as const,
description: "ACP 协议实现",
status: "installing" as const,
},
{
name: "qimingcode",
displayName: "QimingCode",
type: "npm-local" as const,
description: "QimingCode 引擎",
status: "missing" as const,
},
{
name: "qiming-file-server",
displayName: "File Server",
type: "npm-local" as const,
description: "本地文件服务",
status: "missing" as const,
},
{
name: "qiming-mcp-stdio-proxy",
displayName: "MCP Proxy",
type: "npm-local" as const,
description: "MCP 聚合代理",
status: "missing" as const,
},
];
const MOCK_DEPS_COMPLETED = [
{
name: "uv",
displayName: "uv",
type: "system" as const,
description: "Python 包管理器",
status: "installed" as const,
version: "0.5.0",
},
{
name: "claude-code-acp-ts",
displayName: "Claude Code ACP",
type: "npm-local" as const,
description: "ACP 协议实现",
status: "installed" as const,
version: "1.0.0",
},
{
name: "qimingcode",
displayName: "QimingCode",
type: "npm-local" as const,
description: "QimingCode 引擎",
status: "installed" as const,
version: "1.0.0",
},
{
name: "qiming-file-server",
displayName: "File Server",
type: "npm-local" as const,
description: "本地文件服务",
status: "installed" as const,
version: "1.0.0",
},
{
name: "qiming-mcp-stdio-proxy",
displayName: "MCP Proxy",
type: "npm-local" as const,
description: "MCP 聚合代理",
status: "installed" as const,
version: "1.0.0",
},
];
const MOCK_DEPS_ERROR = [
{
name: "uv",
displayName: "uv",
type: "system" as const,
description: "Python 包管理器",
status: "installed" as const,
version: "0.5.0",
},
{
name: "claude-code-acp-ts",
displayName: "Claude Code ACP",
type: "npm-local" as const,
description: "ACP 协议实现",
status: "installed" as const,
version: "1.0.0",
},
{
name: "qimingcode",
displayName: "QimingCode",
type: "npm-local" as const,
description: "QimingCode 引擎",
status: "error" as const,
errorMessage: "网络超时,安装失败",
},
{
name: "qiming-file-server",
displayName: "File Server",
type: "npm-local" as const,
description: "本地文件服务",
status: "missing" as const,
},
{
name: "qiming-mcp-stdio-proxy",
displayName: "MCP Proxy",
type: "npm-local" as const,
description: "MCP 聚合代理",
status: "missing" as const,
},
];
export default function SetupWizardTest() {
const [scenario, setScenario] = useState<TestScenario>("full-install");
const [key, setKey] = useState(0);
const [showReal, setShowReal] = useState(false);
const [simulateSlow, setSimulateSlow] = useState(false);
// 根据 scenario 创建 mock API
const mockApi = useMemo((): MockDependenciesApi | undefined => {
if (showReal) return undefined; // 使用真实 API
const delay = simulateSlow ? 1500 : 300;
const getDepsForPhase = () => {
switch (SCENARIO_CONFIG[scenario].depsPhase) {
case "installing":
return MOCK_DEPS_INSTALLING;
case "error":
return MOCK_DEPS_ERROR;
case "completed":
return MOCK_DEPS_COMPLETED;
default:
return [];
}
};
return {
checkAll: async () => {
await new Promise((r) => setTimeout(r, delay));
return {
success: true,
results: getDepsForPhase().map((d) => ({
...d,
minVersion: d.version,
installVersion: "latest",
})),
};
},
checkUv: async () => {
await new Promise((r) => setTimeout(r, 100));
return {
success: true,
installed: true,
bundled: true,
version: "0.5.0",
};
},
installPackage: async (name: string) => {
await new Promise((r) => setTimeout(r, simulateSlow ? 2000 : 500));
if (name === "qimingcode" && scenario === "error-flow") {
return { success: false, error: "网络超时,安装失败" };
}
return { success: true, version: "1.0.0" };
},
openExternal: async () => {},
};
}, [scenario, showReal, simulateSlow]);
// 场景切换时重置组件
const handleScenarioChange = (value: TestScenario) => {
setScenario(value);
setKey((k) => k + 1);
};
const handleComplete = () => {
message.success(t("Claw.Setup.wizardComplete"));
};
return (
<div style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
<Card title="初始化向导完整流程测试" size="small">
<Space direction="vertical" style={{ width: "100%" }} gap="middle">
{/* 场景选择 */}
<div>
<div style={{ fontSize: 12, marginBottom: 8, fontWeight: 500 }}>
</div>
<Radio.Group
value={scenario}
onChange={(e) => handleScenarioChange(e.target.value)}
optionType="button"
buttonStyle="solid"
size="small"
>
{Object.entries(SCENARIO_CONFIG).map(([key, config]) => (
<Radio.Button key={key} value={key}>
{config.label}
</Radio.Button>
))}
</Radio.Group>
<div
style={{
fontSize: 11,
color: "var(--color-text-tertiary)",
marginTop: 4,
}}
>
{SCENARIO_CONFIG[scenario].description}
</div>
</div>
{/* 选项 */}
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Switch
size="small"
checked={simulateSlow}
onChange={setSimulateSlow}
/>
<span style={{ fontSize: 12 }}></span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Switch
size="small"
checked={showReal}
onChange={(v) => {
setShowReal(v);
setKey((k) => k + 1);
}}
/>
<span style={{ fontSize: 12 }}>使 API</span>
</div>
</div>
{/* 快捷操作 */}
<div style={{ display: "flex", gap: 8 }}>
<Button
size="small"
onClick={() => {
// 重置 setup 状态
window.electronAPI?.settings.set("setup_state", null);
message.info(t("Claw.Setup.resetComplete"));
}}
>
</Button>
<Button size="small" onClick={() => setKey((k) => k + 1)}>
</Button>
</div>
</Space>
</Card>
<Divider style={{ margin: "16px 0" }} />
{/* 向导组件容器 */}
<div
style={{
height: 600,
border: "1px solid var(--color-border)",
borderRadius: 8,
overflow: "hidden",
background: "var(--color-bg-layout)",
}}
>
<SetupWizard
key={key}
onComplete={handleComplete}
mockApi={showReal ? undefined : mockApi}
skipDependencyCheck={
scenario === "deps-ready" || scenario === "already-logged-in"
}
mockLoggedIn={scenario === "already-logged-in"}
/>
</div>
<div
style={{
fontSize: 11,
color: "var(--color-text-tertiary)",
marginTop: 8,
textAlign: "center",
}}
>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useState, useEffect } from "react";
import {
permissionManager,
PermissionRequest,
} from "../../services/agents/permissions";
import { t } from "../../services/core/i18n";
interface PermissionModalProps {
isOpen: boolean;
request: PermissionRequest | null;
onApprove: (alwaysAllow: boolean) => void;
onDeny: () => void;
}
function PermissionModal({
isOpen,
request,
onApprove,
onDeny,
}: PermissionModalProps) {
const [countdown, setCountdown] = useState(30);
useEffect(() => {
if (isOpen && request) {
setCountdown(30);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [isOpen, request]);
if (!isOpen || !request) return null;
const getIcon = () => {
switch (request.type) {
case "command":
return "🖥️";
case "file":
return "📁";
case "network":
return "🌐";
case "tool":
return "🔧";
default:
return "⚠️";
}
};
const getDetails = () => {
const details = request.details;
switch (request.type) {
case "command":
return (
<div className="permission-details">
<div className="detail-row">
<span className="label">{t("Claw.Permissions.command")}:</span>
<code>
{details.command} {details.args?.join(" ")}
</code>
</div>
{details.env && (
<div className="detail-row">
<span className="label">{t("Claw.Permissions.envVars")}:</span>
<span>{Object.keys(details.env).join(", ")}</span>
</div>
)}
</div>
);
case "file":
return (
<div className="permission-details">
<div className="detail-row">
<span className="label">{t("Claw.Permissions.file")}:</span>
<code>{details.file}</code>
</div>
</div>
);
case "network":
return (
<div className="permission-details">
<div className="detail-row">
<span className="label">URL:</span>
<code>{details.url}</code>
</div>
</div>
);
case "tool":
return (
<div className="permission-details">
<div className="detail-row">
<span className="label">{t("Claw.Permissions.tool")}:</span>
<code>{details.tool}</code>
</div>
</div>
);
default:
return null;
}
};
return (
<div className="modal-overlay permission-modal-overlay">
<div className="permission-modal">
<div className="permission-header">
<span className="permission-icon">{getIcon()}</span>
<div className="permission-title">
<h3>{request.title}</h3>
<span className="permission-timer">
{countdown} {t("Claw.Permissions.second")}
</span>
</div>
</div>
<div className="permission-body">
<p className="permission-desc">{request.description}</p>
{getDetails()}
</div>
<div className="permission-actions">
<button className="deny-btn" onClick={onDeny}>
{t("Claw.Permissions.deny")}
</button>
<button className="approve-once-btn" onClick={() => onApprove(false)}>
{t("Claw.Permissions.allowOnce")}
</button>
<button
className="approve-always-btn"
onClick={() => onApprove(true)}
>
{t("Claw.Permissions.allowAlways")}
</button>
</div>
</div>
</div>
);
}
export default PermissionModal;

View File

@@ -0,0 +1,711 @@
/**
* 关于页面 (Electron 版)
*
* - 版本号运行时从 Electron 主进程获取
* - 检查更新 + 下载 + 重启安装 完整流程
* - 下载完成后弹窗确认是否立即重启安装
* - Windows MSI 安装用户引导到官网下载安装页
* - macOS/Linux 上 Squirrel 不发送 download-progress用本地模拟进度保证进度条有变化
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Button,
Progress,
message,
Space,
Modal,
Switch,
Typography,
} from "antd";
import {
SyncOutlined,
DownloadOutlined,
LinkOutlined,
} from "@ant-design/icons";
import { APP_DISPLAY_NAME } from "@shared/constants";
import { t } from "../../services/core/i18n";
import type { UpdateState } from "@shared/types/updateTypes";
/** 官网地址,用于关于页「官网」链接 */
const OFFICIAL_WEBSITE_URL = "https://qiming.com";
/** macOS/Linux 无 download-progress 时,模拟进度从 0 增长到该值(% */
const SIMULATED_PROGRESS_CAP = 90;
/** 模拟进度更新间隔ms */
const SIMULATED_PROGRESS_INTERVAL_MS = 500;
/** 预计下载时长ms用于计算每 tick 的增量,约 45s 内从 0 到 SIMULATED_PROGRESS_CAP */
const SIMULATED_DURATION_MS = 45_000;
type UpdateChannel = "stable" | "beta";
const UPDATE_CHANNEL_SETTING_KEY = "update_channel";
export default function AboutPage() {
const [updateState, setUpdateState] = useState<UpdateState>({
status: "idle",
});
const [appVersion, setAppVersion] = useState<string>("");
const hasShownInstallModal = useRef(false);
const [installing, setInstalling] = useState(false);
/** macOS/Linux 无真实进度时的模拟进度0..SIMULATED_PROGRESS_CAP有 progress 时不用 */
const [simulatedPercent, setSimulatedPercent] = useState(0);
const simulatedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
/** 调试模式:显示升级检测详细信息 */
const [showDebugInfo, setShowDebugInfo] = useState(false);
const [debugInfo, setDebugInfo] = useState<any>(null);
const [updateChannel, setUpdateChannel] = useState<UpdateChannel>("stable");
const [channelLoading, setChannelLoading] = useState(false);
// 监听主进程推送的更新状态
// 注意preload 的 on() 已剥离 IPC eventcallback 直接收到 (...args)
useEffect(() => {
const handler = (state: UpdateState) => {
if (state) setUpdateState(state);
};
window.electronAPI?.on("update:status", handler as any);
// 获取运行时版本号
window.electronAPI?.app?.getVersion().then((v) => {
if (v) setAppVersion(v);
});
// 初始化时获取一次当前更新状态
window.electronAPI?.app?.getUpdateState?.()?.then((state) => {
if (state) setUpdateState(state);
});
// 读取更新通道;旧版本默认按 stable 处理,避免影响已安装用户行为
window.electronAPI?.settings
.get(UPDATE_CHANNEL_SETTING_KEY)
.then((saved) => {
setUpdateChannel(saved === "beta" ? "beta" : "stable");
})
.catch(() => {
setUpdateChannel("stable");
});
return () => {
window.electronAPI?.off("update:status", handler as any);
};
}, []);
const handleCheckUpdate = useCallback(async () => {
setUpdateState((prev) => ({ ...prev, status: "checking" }));
try {
const result = await window.electronAPI?.app?.checkUpdate();
// IPC 不可用API 层返回空),直接恢复 idle
if (!result) {
setUpdateState({ status: "idle" });
return;
}
// 上一次检查仍在进行中,本次被跳过;不显示 toast等待 update:status 事件。
// 但启动检查可能在 IPC 往返途中恰好已完成且不再发事件,
// 调一次 getUpdateState() 防止 'checking' 状态永久卡住。
if (result.alreadyChecking) {
const s = await window.electronAPI?.app?.getUpdateState?.();
if (s) setUpdateState(s);
return;
}
// 根据检查结果显示 toast仅负责消息提示不在这里推算状态
if (result.error) {
message.error(
t("Claw.About.checkFailedWithDetail", { error: result.error }),
);
} else if (!result.hasUpdate) {
message.info(t("Claw.About.alreadyLatest"));
}
// 从主进程获取权威状态(含 canAutoUpdate避免 IPC 事件与 invoke 响应
// 竞争条件导致 Windows MSI 用户看到错误按钮或状态卡住
const authoritative = await window.electronAPI?.app?.getUpdateState?.();
if (authoritative) {
setUpdateState(authoritative);
} else if (result.error || !result.hasUpdate) {
// getUpdateState 不可用时的兜底
setUpdateState({ status: "idle" });
}
} catch {
message.error(t("Claw.About.checkFailed"));
setUpdateState({ status: "idle" });
}
}, []);
const handleChangeUpdateChannel = useCallback(
async (checked: boolean) => {
const nextChannel: UpdateChannel = checked ? "beta" : "stable";
if (nextChannel === updateChannel) return;
// 切换到 beta 时:弹出二次确认
if (nextChannel === "beta") {
Modal.confirm({
title: t("Claw.About.channelSwitching"),
content: (
<div>
<p>{t("Claw.About.betaWarning")}</p>
<p>{t("Claw.About.confirmSwitch")}</p>
</div>
),
okText: t("Claw.About.confirmSwitchBtn"),
cancelText: t("Claw.Common.cancel"),
onOk: async () => {
setChannelLoading(true);
try {
await window.electronAPI?.settings.set(
UPDATE_CHANNEL_SETTING_KEY,
"beta",
);
setUpdateChannel("beta");
message.success(t("Claw.About.switchedToBeta"));
// 确认后自动触发一次 beta 通道的升级检查
await handleCheckUpdate();
} catch {
message.error(t("Claw.About.channelSwitchFailed"));
} finally {
setChannelLoading(false);
}
},
});
return;
}
// 切换回 stable直接切换无确认弹框重新检查 stable 通道
setChannelLoading(true);
try {
await window.electronAPI?.settings.set(
UPDATE_CHANNEL_SETTING_KEY,
"stable",
);
setUpdateChannel("stable");
message.success(t("Claw.About.switchedToStable"));
await handleCheckUpdate();
} catch {
message.error(t("Claw.About.channelSwitchFailed"));
} finally {
setChannelLoading(false);
}
},
[updateChannel, handleCheckUpdate],
);
// macOS/LinuxSquirrel 不发送 download-progress用定时器模拟进度使进度条有变化
useEffect(() => {
const isDownloading = updateState.status === "downloading";
const hasRealProgress = updateState.progress != null;
if (isDownloading && !hasRealProgress) {
setSimulatedPercent(0);
const increment =
(SIMULATED_PROGRESS_CAP / SIMULATED_DURATION_MS) *
SIMULATED_PROGRESS_INTERVAL_MS;
const id = setInterval(() => {
setSimulatedPercent((prev) => {
const next = prev + increment;
return next >= SIMULATED_PROGRESS_CAP ? SIMULATED_PROGRESS_CAP : next;
});
}, SIMULATED_PROGRESS_INTERVAL_MS);
simulatedIntervalRef.current = id;
return () => {
clearInterval(id);
simulatedIntervalRef.current = null;
};
}
if (!isDownloading || hasRealProgress) {
if (simulatedIntervalRef.current) {
clearInterval(simulatedIntervalRef.current);
simulatedIntervalRef.current = null;
}
setSimulatedPercent(0);
}
}, [updateState.status, updateState.progress]);
// 下载完成后自动弹窗确认安装
useEffect(() => {
if (updateState.status === "downloaded" && !hasShownInstallModal.current) {
hasShownInstallModal.current = true;
const modal = Modal.confirm({
title: t("Claw.About.updateDownloaded"),
content: t("Claw.About.updateDownloadedConfirm", {
version: updateState.version,
}),
okText: t("Claw.About.restartNow"),
cancelText: t("Claw.About.later"),
okButtonProps: { loading: false },
onOk: async () => {
modal.update({ okButtonProps: { loading: true } });
try {
const result = await window.electronAPI?.app?.installUpdate?.();
if (!result || !result.success) {
const errorMessage =
result?.error || t("Claw.About.installFailed");
message.error(errorMessage);
modal.update({ okButtonProps: { loading: false } });
return Promise.reject(new Error(errorMessage));
}
} catch {
message.error(t("Claw.About.installFailed"));
modal.update({ okButtonProps: { loading: false } });
return Promise.reject(new Error(t("Claw.About.installFailed")));
}
},
});
}
// 状态回到非 downloaded 时重置标记
if (updateState.status !== "downloaded") {
hasShownInstallModal.current = false;
}
}, [updateState.status, updateState.version]);
const handleDownload = useCallback(async () => {
// 立即切换到 downloading 状态,避免点击后无反馈
setUpdateState((prev) => ({
...prev,
status: "downloading",
progress: undefined,
}));
try {
const result = await window.electronAPI?.app?.downloadUpdate?.();
if (!result || !result.success) {
message.error(result?.error || t("Claw.About.downloadFailed"));
setUpdateState((prev) => ({ ...prev, status: "available" }));
}
} catch {
message.error(t("Claw.About.downloadFailed"));
setUpdateState((prev) => ({ ...prev, status: "available" }));
}
}, []);
const handleInstall = useCallback(async () => {
setInstalling(true);
try {
const result = await window.electronAPI?.app?.installUpdate?.();
if (!result || !result.success) {
message.error(result?.error || t("Claw.About.installFailed"));
setInstalling(false);
}
} catch {
message.error(t("Claw.About.installFailed"));
setInstalling(false);
}
}, []);
const handleOpenReleases = useCallback(() => {
window.electronAPI?.app?.openReleasesPage?.();
}, []);
/** 在系统默认浏览器中打开官网 */
const handleOpenOfficialWebsite = useCallback(async () => {
try {
await window.electronAPI?.shell?.openExternal(OFFICIAL_WEBSITE_URL);
} catch (e) {
console.error("[AboutPage] openExternal failed:", e);
}
}, []);
/** 获取调试信息 */
const handleGetDebugInfo = useCallback(async () => {
try {
const info = await window.electronAPI?.app?.getUpdateDebugInfo?.();
if (info && info.success) {
setDebugInfo(info);
setShowDebugInfo(true);
} else {
message.error(t("Claw.About.getDebugInfoFailed"));
}
} catch (e) {
console.error("[AboutPage] getUpdateDebugInfo failed:", e);
message.error(t("Claw.About.getDebugInfoFailed"));
}
}, []);
const renderUpdateSection = () => {
const {
status,
version,
progress,
error,
canAutoUpdate: autoUpdate,
isReadOnlyVolumeError: readOnlyVolume,
} = updateState ?? { status: "idle" as const };
switch (status) {
case "checking":
return (
<Button icon={<SyncOutlined spin />} disabled>
{t("Claw.About.checking")}
</Button>
);
case "available":
return (
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<div style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
{t("Claw.About.versionFound", { version })}
</div>
{autoUpdate === false ? (
<Button
type="primary"
icon={<LinkOutlined />}
onClick={handleOpenReleases}
>
{t("Claw.About.goToDownloadPage")}
</Button>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
>
{t("Claw.About.downloadUpdate")}
</Button>
)}
</Space>
);
case "downloading": {
// 有真实进度(如 Windows用主进程推送的 progress无则用本地模拟进度macOS/Linux
const displayPercent =
progress != null
? Math.round(progress.percent)
: Math.round(simulatedPercent);
return (
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<div style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
{t("Claw.About.downloading", {
version,
percent: displayPercent,
})}
</div>
<div
style={{
padding: "8px 0",
borderTop: "1px solid var(--color-border)",
borderBottom: "1px solid var(--color-border)",
}}
>
<Progress
percent={displayPercent}
size="small"
status="active"
showInfo={progress == null}
strokeColor="var(--color-primary)"
/>
</div>
</Space>
);
}
case "downloaded":
return (
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<div style={{ fontSize: 12, color: "var(--color-success)" }}>
{t("Claw.About.versionDownloaded", { version })}
</div>
<Button type="primary" onClick={handleInstall} loading={installing}>
{t("Claw.About.installUpdate")}
</Button>
</Space>
);
case "error":
// 只读卷错误(如从「下载」直接打开):无法就地更新,引导用户前往下载页或移动应用后重试
if (readOnlyVolume) {
return (
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<div
style={{
fontSize: 12,
color: "var(--color-text-secondary)",
lineHeight: 1.5,
}}
>
{t("Claw.About.readOnlyVolumeError")}
</div>
<Space>
<Button
type="primary"
icon={<LinkOutlined />}
onClick={handleOpenReleases}
>
{t("Claw.About.goToDownloadPage")}
</Button>
<Button icon={<SyncOutlined />} onClick={handleCheckUpdate}>
{t("Claw.Common.retry")}
</Button>
</Space>
</Space>
);
}
return (
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<div style={{ fontSize: 12, color: "var(--color-error)" }}>
{error || t("Claw.About.updateError")}
</div>
<Button icon={<SyncOutlined />} onClick={handleCheckUpdate}>
{t("Claw.Common.retry")}
</Button>
</Space>
);
default:
return (
<Button icon={<SyncOutlined />} onClick={handleCheckUpdate}>
{t("Claw.About.checkUpdate")}
</Button>
);
}
};
return (
<div
style={{
width: 400,
margin: "48px auto",
textAlign: "center",
}}
>
<div
style={{
border: "1px solid var(--color-border)",
borderRadius: 12,
background: "var(--color-bg-section)",
padding: "40px 32px",
}}
>
<img
src="./icon.png"
alt={APP_DISPLAY_NAME}
style={{
width: 64,
height: 64,
borderRadius: 16,
}}
/>
<div
style={{
marginTop: 20,
fontSize: 20,
fontWeight: 600,
color: "var(--color-text)",
}}
>
{APP_DISPLAY_NAME}
</div>
<div
style={{
marginTop: 8,
fontSize: 16,
color: "var(--color-text-secondary)",
fontWeight: 500,
}}
>
v{appVersion || "..."}
</div>
<div
style={{
marginTop: 16,
fontSize: 14,
color: "var(--color-text-tertiary)",
lineHeight: 1.6,
}}
>
{t("Claw.About.crossPlatformDescription")}
</div>
{/* 官网链接:点击在系统浏览器打开 qiming.com */}
<div style={{ marginTop: 12 }}>
<span
role="button"
tabIndex={0}
onClick={handleOpenOfficialWebsite}
onKeyDown={(e) => e.key === "Enter" && handleOpenOfficialWebsite()}
style={{
fontSize: 13,
color: "var(--color-text-secondary)",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<LinkOutlined />
{t("Claw.About.website")} {OFFICIAL_WEBSITE_URL}
</span>
</div>
<div style={{ marginTop: 24 }}>{renderUpdateSection()}</div>
<div
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
flexWrap: "wrap",
}}
>
<Typography.Text
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
{t("Claw.About.betaChannel")}
</Typography.Text>
<Switch
size="small"
checked={updateChannel === "beta"}
loading={channelLoading}
onChange={handleChangeUpdateChannel}
/>
</div>
<div
style={{
marginTop: 8,
fontSize: 12,
color: "var(--color-text-tertiary)",
lineHeight: 1.5,
}}
>
{t("Claw.About.betaDisclaimer")}
</div>
</div>
{/* 调试面板 */}
<div style={{ marginTop: 16, textAlign: "center" }}>
<Button
type="link"
size="small"
onClick={
showDebugInfo ? () => setShowDebugInfo(false) : handleGetDebugInfo
}
style={{ fontSize: 12, color: "var(--color-text-tertiary)" }}
>
{showDebugInfo
? t("Claw.About.hideDebugInfo")
: t("Claw.About.showDebugInfo")}
</Button>
</div>
{showDebugInfo && debugInfo && (
<div
style={{
marginTop: 16,
padding: 16,
border: "1px dashed var(--color-border)",
borderRadius: 8,
background: "var(--color-bg-elevated)",
fontSize: 12,
fontFamily: "monospace",
textAlign: "left",
}}
>
<div style={{ marginBottom: 8, fontWeight: 600 }}>
{t("Claw.About.debugInfoTitle")}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "auto 1fr",
gap: "8px 16px",
}}
>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.platform")}:
</span>
<span>{debugInfo.platform}</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.arch")}:
</span>
<span>{debugInfo.arch}</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.packaged")}:
</span>
<span>
{debugInfo.isPackaged
? t("Claw.Common.yes")
: t("Claw.About.devMode")}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.appVersion")}:
</span>
<span>{debugInfo.appVersion}</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.appName")}:
</span>
<span>{debugInfo.appName}</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.installerType")}:
</span>
<span
style={{
color:
debugInfo.installerType === "nsis"
? "var(--color-success)"
: debugInfo.installerType === "msi"
? "var(--color-warning)"
: "inherit",
fontWeight: 500,
}}
>
{debugInfo.installerType?.toUpperCase()}
{debugInfo.installerType === "nsis" &&
" " + t("Claw.About.upgradeSupported")}
{debugInfo.installerType === "msi" &&
" " + t("Claw.About.manualDownload")}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.canAutoUpdate")}:
</span>
<span
style={{
color: debugInfo.canAutoUpdate
? "var(--color-success)"
: "var(--color-error)",
fontWeight: 500,
}}
>
{debugInfo.canAutoUpdate
? t("Claw.Common.yes")
: t("Claw.Common.no")}
</span>
{!debugInfo.isPackaged && (
<>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.appDir")}:
</span>
<span style={{ wordBreak: "break-all" }}>
{debugInfo.appDir}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.exePath")}:
</span>
<span style={{ wordBreak: "break-all" }}>
{debugInfo.exePath}
</span>
</>
)}
{debugInfo.uninstallerFiles &&
debugInfo.uninstallerFiles.length > 0 && (
<>
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.uninstaller")}:
</span>
<span>{debugInfo.uninstallerFiles.join(", ")}</span>
</>
)}
<span style={{ color: "var(--color-text-secondary)" }}>
{t("Claw.About.totalAppFiles")}:
</span>
<span>{debugInfo.totalAppFiles}</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,349 @@
/**
* LogViewer - 日志查看器 (Electron 版)
*
* - 日志列表(时间戳 + 级别 + 消息)、级别筛选、自动滚动、刷新
* - 向上滚动到顶部时自动加载更早的日志(分页,保持滚动位置)
*/
import React, {
useState,
useEffect,
useRef,
useCallback,
useLayoutEffect,
} from "react";
import { Button, Select, Switch, Empty, Spin, Tag } from "antd";
import { ReloadOutlined, VerticalAlignBottomOutlined } from "@ant-design/icons";
import { t } from "@renderer/services/core/i18n";
import { I18N_KEYS } from "@shared/constants";
import styles from "../../styles/components/ClientPage.module.css";
interface LogEntry {
timestamp: string;
level: string;
message: string;
}
const LEVEL_TAG_COLORS: Record<string, string> = {
error: "red",
warn: "orange",
info: "blue",
debug: "default",
};
/** 每页条数,与主进程默认一致 */
const PAGE_SIZE = 2000;
/** 距顶部多少 px 时触发加载更多 */
const LOAD_MORE_THRESHOLD = 80;
export default function LogViewer() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
const [autoScroll, setAutoScroll] = useState(true);
const listRef = useRef<HTMLDivElement>(null);
/** prepend 后用于恢复滚动位置 */
const scrollRestoreRef = useRef<{ height: number; top: number } | null>(null);
/** 标记是否正在加载更早日志prepend 模式,不触发自动滚动) */
const isLoadingOlderRef = useRef(false);
/** 首次加载或刷新:取最新一页 */
const fetchLogs = useCallback(async () => {
try {
const result = await window.electronAPI?.log?.list(PAGE_SIZE, 0);
if (result) {
setLogs(result);
setHasMore(result.length >= PAGE_SIZE);
}
} catch (error) {
console.error("[LogViewer] Failed to fetch logs:", error);
} finally {
setLoading(false);
}
}, []);
/** 向上加载更早的一页prepend 并保持滚动位置 */
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return;
const el = listRef.current;
if (!el) return;
setLoadingMore(true);
isLoadingOlderRef.current = true; // 标记正在加载更早日志
try {
const offset = logs.length;
const older = await window.electronAPI?.log?.list(PAGE_SIZE, offset);
if (older && older.length > 0) {
scrollRestoreRef.current = {
height: el.scrollHeight,
top: el.scrollTop,
};
setLogs((prev) => [...older, ...prev]);
setHasMore(older.length >= PAGE_SIZE);
} else {
setHasMore(false);
}
} catch (error) {
console.error("[LogViewer] Load more failed:", error);
} finally {
setLoadingMore(false);
// 延迟重置标记,让 useLayoutEffect 先完成滚动恢复
setTimeout(() => {
isLoadingOlderRef.current = false;
}, 0);
}
}, [logs.length, hasMore, loadingMore]);
useEffect(() => {
fetchLogs();
const timer = setInterval(fetchLogs, 5000);
return () => clearInterval(timer);
}, [fetchLogs]);
/** prepend 后恢复滚动位置,避免列表跳动 */
useLayoutEffect(() => {
const ref = scrollRestoreRef.current;
const el = listRef.current;
if (!ref || !el) return;
const diff = el.scrollHeight - ref.height;
if (diff > 0) {
el.scrollTop = ref.top + diff;
}
scrollRestoreRef.current = null;
}, [logs]);
/** 仅当新日志追加到底部时自动滚动(非 prepend 场景) */
useEffect(() => {
if (!autoScroll || !listRef.current) return;
// 如果正在加载更早日志,跳过自动滚动
if (isLoadingOlderRef.current) return;
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [logs, autoScroll]);
/** 滚动到顶时加载更多 */
const handleScroll = useCallback(() => {
const el = listRef.current;
if (!el || autoScroll || loadingMore || !hasMore) return;
if (el.scrollTop <= LOAD_MORE_THRESHOLD) {
loadMore();
}
}, [autoScroll, loadingMore, hasMore, loadMore]);
const handleRefresh = () => {
setLoading(true);
setHasMore(true);
fetchLogs();
};
const handleOpenDir = async () => {
await window.electronAPI?.log?.openDir();
};
const filteredLogs =
levelFilter === "all" ? logs : logs.filter((l) => l.level === levelFilter);
return (
<div
style={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
overflow: "hidden",
}}
>
{/* Header */}
<div
className={styles.section}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
marginBottom: 0,
}}
>
<div className={styles.servicesHeader}>
<div className={styles.servicesHeaderLeft}>
<span className={styles.sectionTitle}>
{t(I18N_KEYS.Pages.LogViewer.TITLE)}
</span>
<span
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
{t(I18N_KEYS.Pages.LogViewer.AUTO_SCROLL)}
</span>
<Switch
size="small"
checked={autoScroll}
onChange={setAutoScroll}
/>
<Select
size="small"
value={levelFilter}
onChange={setLevelFilter}
style={{ width: 90 }}
options={[
{ label: t(I18N_KEYS.Pages.LogViewer.ALL), value: "all" },
{ label: "Error", value: "error" },
{ label: "Warn", value: "warn" },
{ label: "Info", value: "info" },
{ label: "Debug", value: "debug" },
]}
/>
</div>
<div className={styles.servicesHeaderActions}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
>
{t(I18N_KEYS.Pages.LogViewer.REFRESH)}
</Button>
<Button size="small" onClick={handleOpenDir}>
{t(I18N_KEYS.Pages.LogViewer.OPEN_DIR)}
</Button>
</div>
</div>
{/* Log list占满剩余高度无日志时空状态也撑满 */}
<div
style={{
flex: 1,
minHeight: 0,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{loading && logs.length === 0 ? (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Spin size="small" />
</div>
) : filteredLogs.length === 0 ? (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Empty
description={t(I18N_KEYS.Pages.LogViewer.NO_LOGS)}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
) : (
<div
ref={listRef}
onScroll={handleScroll}
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
padding: "8px 0",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12,
lineHeight: 1.7,
background: "var(--color-bg-container)",
}}
>
{hasMore && (
<div
style={{
padding: "8px 12px",
color: "var(--color-text-tertiary)",
fontSize: 11,
textAlign: "center",
}}
>
{loadingMore ? (
<Spin size="small" />
) : (
t(I18N_KEYS.Pages.LogViewer.SCROLL_TO_LOAD_MORE)
)}
</div>
)}
{filteredLogs.map((entry, idx) => (
<div
key={`${idx}-${entry.timestamp}-${entry.message?.slice(0, 100)}`}
style={{
padding: "1px 12px",
display: "flex",
gap: 8,
alignItems: "flex-start",
}}
>
<span
style={{
color: "var(--color-text-tertiary)",
flexShrink: 0,
}}
>
{entry.timestamp}
</span>
<Tag
color={LEVEL_TAG_COLORS[entry.level] || "default"}
style={{
margin: 0,
fontSize: 10,
lineHeight: "18px",
flexShrink: 0,
}}
>
{entry.level.toUpperCase()}
</Tag>
<span
style={{
color:
entry.level === "error"
? "var(--color-error)"
: entry.level === "warn"
? "var(--color-warning)"
: entry.level === "info"
? "var(--color-info)"
: "var(--color-text)",
wordBreak: "break-all",
}}
>
{entry.message}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div
style={{
padding: "8px 16px",
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--color-text-tertiary)",
}}
>
<span>
{t(I18N_KEYS.Pages.LogViewer.TOTAL_LOGS, filteredLogs.length)}
</span>
{autoScroll && (
<span>
<VerticalAlignBottomOutlined style={{ marginRight: 4 }} />
{t(I18N_KEYS.Pages.LogViewer.AUTO_SCROLL_ENABLED)}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
/**
* 模型页面
*
* 独立 Tab 页面,承载 GUIAgentSettings 配置组件。
* GUIAgentSettings 本是 Modal设计为 isOpen/onClose 契约;
* 此处作为独立页面直接渲染,始终保持 open 状态。
*
* 当 ENABLE_GUI_AGENT_SERVER=false 时,不渲染 GUI Agent 设置。
*/
import React, { useState, useEffect } from "react";
import { Result } from "antd";
import GUIAgentSettings from "../settings/GUIAgentSettings";
import { FEATURES } from "@shared/featureFlags";
export default function ModelPage() {
// 作为独立页面直接渲染isOpen 始终为 trueonClose 不需要实际关闭
const [isOpen] = useState(true);
// DEBUG: 打印 FEATURES 值到控制台
useEffect(() => {
console.log(
"[DEBUG ModelPage] FEATURES.ENABLE_GUI_AGENT_SERVER =",
FEATURES.ENABLE_GUI_AGENT_SERVER,
);
console.log(
"[DEBUG ModelPage] FEATURES.INJECT_GUI_MCP =",
FEATURES.INJECT_GUI_MCP,
);
console.log(
"[DEBUG ModelPage] FEATURES.LOG_FULL_SECRETS =",
FEATURES.LOG_FULL_SECRETS,
);
}, []);
if (!FEATURES.ENABLE_GUI_AGENT_SERVER) {
return (
<Result
status="403"
title="GUI Agent Server is disabled"
subTitle={
<div>
<p>This feature is not available in the current configuration.</p>
<p style={{ color: "red", marginTop: 16, fontWeight: "bold" }}>
[DEBUG] FEATURES.ENABLE_GUI_AGENT_SERVER ={" "}
{String(FEATURES.ENABLE_GUI_AGENT_SERVER)}
</p>
</div>
}
/>
);
}
return (
<div style={{ padding: 16 }}>
<div
style={{
color: "green",
fontWeight: "bold",
marginBottom: 16,
padding: 8,
background: "#e8f5e9",
borderRadius: 4,
}}
>
[DEBUG] FEATURES.ENABLE_GUI_AGENT_SERVER ={" "}
{String(FEATURES.ENABLE_GUI_AGENT_SERVER)}
</div>
<GUIAgentSettings
isOpen={isOpen}
onClose={() => {
/* 独立 Tab 模式下不需要关闭 */
}}
/>
</div>
);
}

View File

@@ -0,0 +1,215 @@
/**
* PermissionsPage - 系统授权页面 (Electron 版, 仅 macOS)
*
* 从 Tauri 客户端 PermissionsPage 简化移植:
* - 权限列表:全磁盘访问、辅助功能、屏幕录制
* - 每项显示状态(已授权/未授权)
* - "前往设置"按钮
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button, Tag, Spin, message } from "antd";
import {
CheckCircleOutlined,
CloseCircleOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SettingOutlined,
} from "@ant-design/icons";
import styles from "../../styles/components/ClientPage.module.css";
import { t } from "../../services/core/i18n";
interface PermissionItem {
key: string;
name: string;
description: string;
status: "granted" | "denied" | "unknown";
}
const STATUS_ICON: Record<string, React.ReactNode> = {
granted: (
<CheckCircleOutlined
style={{ color: "var(--color-success)", fontSize: 16 }}
/>
),
denied: (
<CloseCircleOutlined
style={{ color: "var(--color-error)", fontSize: 16 }}
/>
),
unknown: (
<QuestionCircleOutlined
style={{ color: "var(--color-text-secondary)", fontSize: 16 }}
/>
),
};
const STATUS_TAG: Record<string, { color: string; textKey: string }> = {
granted: { color: "green", textKey: "Claw.PermissionsPage.granted" },
denied: { color: "red", textKey: "Claw.PermissionsPage.denied" },
unknown: { color: "default", textKey: "Claw.PermissionsPage.unknown" },
};
export default function PermissionsPage() {
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const [loading, setLoading] = useState(true);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearPollTimers = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
}, []);
const checkPermissions = useCallback(async () => {
try {
const result = await window.electronAPI?.permissions?.check();
if (result) {
setPermissions(result);
}
} catch (error) {
console.error("[PermissionsPage] Check failed:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkPermissions();
return clearPollTimers;
}, [checkPermissions, clearPollTimers]);
const handleOpenSettings = async (key: string) => {
try {
await window.electronAPI?.permissions?.openSettings(key);
// Poll for changes after user opens settings
clearPollTimers();
pollTimerRef.current = setInterval(async () => {
await checkPermissions();
}, 2000);
pollTimeoutRef.current = setTimeout(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, 30000);
} catch {
message.error(t("Claw.PermissionsPage.cannotOpenSettings"));
}
};
const handleRefresh = () => {
setLoading(true);
checkPermissions();
};
const grantedCount = permissions.filter((p) => p.status === "granted").length;
const totalCount = permissions.length;
if (loading && permissions.length === 0) {
return (
<div className={styles.page}>
<div style={{ textAlign: "center", padding: 40 }}>
<Spin size="small" />
</div>
</div>
);
}
return (
<div className={styles.page}>
{/* Header */}
<div className={styles.section}>
<div className={styles.servicesHeader}>
<div className={styles.servicesHeaderLeft}>
<span className={styles.sectionTitle}>
{t("Claw.PermissionsPage.title")}
</span>
{totalCount > 0 && (
<Tag color={grantedCount === totalCount ? "green" : "orange"}>
{grantedCount}/{totalCount}
</Tag>
)}
<span
className={styles.sectionDescription}
style={{ marginTop: 0, marginLeft: 12 }}
>
{t("Claw.PermissionsPage.description")}
</span>
</div>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
>
{t("Claw.PermissionsPage.refresh")}
</Button>
</div>
{/* Permission list */}
<div className={styles.sectionBody} style={{ padding: "0 16px" }}>
{permissions.map((perm) => (
<div key={perm.key} className={styles.serviceRow}>
<div className={styles.serviceInfo}>
{STATUS_ICON[perm.status]}
<div>
<span className={styles.serviceLabel}>{perm.name}</span>
<div className={styles.serviceDescription}>
{perm.description}
</div>
</div>
</div>
<div className={styles.serviceActions}>
<Tag
color={STATUS_TAG[perm.status]?.color || "default"}
style={{ margin: 0, fontSize: 11 }}
>
{t(
STATUS_TAG[perm.status]?.textKey ||
"Claw.PermissionsPage.unknown",
)}
</Tag>
{perm.status !== "granted" && (
<Button
size="small"
icon={<SettingOutlined />}
onClick={() => handleOpenSettings(perm.key)}
>
{t("Claw.PermissionsPage.openSettings")}
</Button>
)}
</div>
</div>
))}
</div>
</div>
{grantedCount === totalCount && totalCount > 0 && (
<div
style={{
padding: "10px 14px",
background: "var(--color-bg-section)",
border: "1px solid var(--color-border)",
borderRadius: "var(--border-radius-lg)",
fontSize: 12,
color: "var(--color-success)",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<CheckCircleOutlined />
{t("Claw.PermissionsPage.allGranted")}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,397 @@
/**
* SessionsPage - 会话管理页面
*
* 两个视图:
* A. 会话列表 - 展示所有活跃会话,支持打开/停止
* B. 内嵌 webview - 在主窗口内展示会话页面
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button, Tag, message, Spin } from "antd";
import {
PlusOutlined,
ReloadOutlined,
TeamOutlined,
PlayCircleOutlined,
StopOutlined,
} from "@ant-design/icons";
import {
syncCookieAndGetRedirectUrl,
syncCookieAndGetNewSessionUrl,
syncCookieAndGetChatUrl,
persistTicketCookie,
} from "../../services/utils/sessionUrl";
import { logger } from "../../services/utils/logService";
import { APP_DISPLAY_NAME } from "@shared/constants";
import { t } from "../../services/core/i18n";
import type { DetailedSession } from "@shared/types/sessions";
import styles from "../../styles/components/SessionsPage.module.css";
export interface WebviewHeaderActions {
onBack: () => void;
onReload: () => void;
}
interface SessionsPageProps {
/** When true, automatically open webview on mount (used by "开始会话" button). */
autoOpen?: boolean;
/** Called after autoOpen has been consumed, so it doesn't re-trigger. */
onAutoOpenConsumed?: () => void;
/** Notify parent when entering/leaving webview mode (for hiding sidebar/logo). */
onWebviewChange?: (actions: WebviewHeaderActions | null) => void;
}
function SessionsPage({
autoOpen,
onAutoOpenConsumed,
onWebviewChange,
}: SessionsPageProps) {
// ---------- View state ----------
const [view, setView] = useState<"list" | "webview">("list");
const [webviewUrl, setWebviewUrl] = useState("");
const [webviewUA, setWebviewUA] = useState<string | undefined>();
const webviewRef = useRef<HTMLElement | null>(null);
// Build custom user agent with app version
useEffect(() => {
window.electronAPI?.app
.getVersion()
.then((version) => {
const ua = navigator.userAgent + ` ${APP_DISPLAY_NAME}/${version}`;
setWebviewUA(ua);
})
.catch(() => {});
}, []);
// Debug: log webview navigation events to track login redirects
useEffect(() => {
const el = webviewRef.current as any;
if (!el || view !== "webview") return;
const onDidNavigate = (e: any) => {
const url: string = e.url || "(unknown)";
const isLogin = url.includes("/login");
const level = isLogin ? "warn" : "info";
logger[level](
`[SessionsPage][WebviewNav] did-navigate${isLogin ? " ⚠️ LOGIN DETECTED" : ""}`,
"SessionsPage",
{ url, httpCode: e.httpResponseCode, isLogin },
);
// webview 登录成功后(从 /login 跳到非 login 页面),持久化 ticket cookie
if (!isLogin && url.startsWith("http")) {
try {
const origin = new URL(url).origin;
persistTicketCookie(origin).catch(() => {});
} catch {
// URL 解析失败,忽略
}
}
};
const onWillRedirect = (e: any) => {
logger.info("[SessionsPage][WebviewNav] will-redirect", "SessionsPage", {
from: e.oldURL,
to: e.newURL,
});
};
el.addEventListener("did-navigate", onDidNavigate);
el.addEventListener("did-navigate-in-page", onDidNavigate);
el.addEventListener("will-redirect", onWillRedirect);
return () => {
el.removeEventListener("did-navigate", onDidNavigate);
el.removeEventListener("did-navigate-in-page", onDidNavigate);
el.removeEventListener("will-redirect", onWillRedirect);
};
}, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps
// Notify parent when entering/leaving webview
useEffect(() => {
if (view === "webview") {
onWebviewChange?.({
onBack: () => {
setView("list");
setWebviewUrl("");
fetchSessions();
},
onReload: () => {
(webviewRef.current as any)?.reload?.();
},
});
} else {
onWebviewChange?.(null);
}
return () => {
onWebviewChange?.(null);
};
}, [view]); // eslint-disable-line react-hooks/exhaustive-deps
// Note: Ctrl/Cmd+Shift+I for webview DevTools is handled in the main process
// (webviewPolicy.ts) via before-input-event, because keyboard events inside
// a <webview> don't bubble to the host renderer page.
// ---------- Sessions ----------
const [sessions, setSessions] = useState<DetailedSession[]>([]);
const [loading, setLoading] = useState(true);
const [stoppingSessions, setStoppingSessions] = useState<Set<string>>(
new Set(),
);
// ======================== Data fetching ========================
const fetchSessions = useCallback(async () => {
try {
const result = await window.electronAPI?.agent.listSessionsDetailed();
if (result?.success && Array.isArray(result.data)) {
setSessions(result.data);
}
} catch (error) {
console.error("[SessionsPage] fetchSessions failed:", error);
} finally {
setLoading(false);
}
}, []);
// Poll sessions every 3s when in list view
useEffect(() => {
if (view !== "list") return;
fetchSessions();
const timer = setInterval(fetchSessions, 3000);
return () => clearInterval(timer);
}, [fetchSessions, view]);
// ======================== Actions ========================
const handleOpenWebview = useCallback(async () => {
try {
const url = await syncCookieAndGetRedirectUrl();
if (!url) {
message.warning(t("Claw.Sessions.loginFirst"));
return;
}
setWebviewUrl(url);
setView("webview");
} catch (error) {
console.error("[SessionsPage] syncCookieAndGetUrl failed:", error);
message.error(t("Claw.Sessions.getSessionUrlFailed"));
}
}, []);
const handleNewSession = useCallback(async () => {
try {
const url = await syncCookieAndGetNewSessionUrl();
if (!url) {
message.warning(t("Claw.Sessions.loginFirst"));
return;
}
setWebviewUrl(url);
setView("webview");
} catch (error) {
console.error(
"[SessionsPage] syncCookieAndGetNewSessionUrl failed:",
error,
);
message.error(t("Claw.Sessions.getSessionUrlFailed"));
}
}, []);
const handleOpenSession = useCallback(async (sessionId: string) => {
try {
const url = await syncCookieAndGetChatUrl(sessionId);
if (!url) {
message.warning(t("Claw.Sessions.loginFirst"));
return;
}
setWebviewUrl(url);
setView("webview");
} catch (error) {
console.error("[SessionsPage] syncCookieAndGetChatUrl failed:", error);
message.error(t("Claw.Sessions.getSessionUrlFailed"));
}
}, []);
// Auto-open webview when navigated from "开始会话"
useEffect(() => {
if (autoOpen) {
onAutoOpenConsumed?.();
handleOpenWebview();
}
}, [autoOpen, handleOpenWebview, onAutoOpenConsumed]);
const handleStopSession = useCallback(
async (sessionId: string) => {
setStoppingSessions((prev) => new Set(prev).add(sessionId));
try {
const result = await window.electronAPI?.agent.stopSession(sessionId);
if (result?.success) {
message.success(t("Claw.Sessions.sessionStopped"));
await fetchSessions();
} else {
message.error(t("Claw.Sessions.stopSessionFailed"));
}
} catch (error) {
console.error("[SessionsPage] stopSession failed:", error);
message.error(t("Claw.Sessions.stopSessionFailed"));
} finally {
setStoppingSessions((prev) => {
const next = new Set(prev);
next.delete(sessionId);
return next;
});
}
},
[fetchSessions],
);
// ======================== Render helpers ========================
const getStatusTag = (status: DetailedSession["status"]) => {
switch (status) {
case "active":
return <Tag color="processing">{t("Claw.Sessions.statusActive")}</Tag>;
case "pending":
return <Tag color="warning">{t("Claw.Sessions.statusPending")}</Tag>;
case "terminating":
return <Tag color="error">{t("Claw.Sessions.statusTerminating")}</Tag>;
case "idle":
default:
return <Tag>{t("Claw.Sessions.statusIdle")}</Tag>;
}
};
const getEngineTag = (engineType: DetailedSession["engineType"]) => {
return engineType === "claude-code" ? (
<Tag color="blue">{t("Claw.Sessions.engine01")}</Tag>
) : (
<Tag color="purple">{t("Claw.Sessions.engine02")}</Tag>
);
};
const formatTime = (ts: number) => {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
// ======================== Render ========================
// View B: Embedded webview (toolbar is in the app header via onWebviewChange)
if (view === "webview" && webviewUrl) {
return (
<div className={styles.webviewFullscreen}>
<webview
ref={webviewRef as any}
src={webviewUrl}
useragent={webviewUA}
style={{ flex: 1, width: "100%", border: "none" }}
allowpopups={"true" as any}
/>
</div>
);
}
// View A: Session list
return (
<div className={styles.page}>
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
<TeamOutlined
style={{ fontSize: 14, color: "var(--color-text-secondary)" }}
/>
<span className={styles.toolbarTitle}>
{t("Claw.Sessions.title")}
</span>
{sessions.length > 0 && (
<Tag style={{ margin: 0, fontSize: 11 }}>{sessions.length}</Tag>
)}
</div>
<div className={styles.toolbarActions}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={fetchSessions}
>
{t("Claw.Sessions.refresh")}
</Button>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={handleNewSession}
>
{t("Claw.Sessions.newSession")}
</Button>
</div>
</div>
{/* Session list or empty state */}
{loading ? (
<div className={styles.emptyState}>
<Spin size="default" />
</div>
) : sessions.length === 0 ? (
<div className={styles.emptyState}>
<TeamOutlined className={styles.emptyIcon} />
<span>{t("Claw.Sessions.noActiveSessions")}</span>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleNewSession}
>
{t("Claw.Sessions.newSession")}
</Button>
</div>
) : (
<div className={styles.sessionList}>
{sessions.map((session) => {
const isStopping = stoppingSessions.has(session.id);
return (
<div key={session.id} className={styles.sessionRow}>
<div className={styles.sessionInfo}>
<span className={styles.sessionTitle}>
{session.title || session.id.substring(0, 12)}
</span>
<div className={styles.sessionMeta}>
{getEngineTag(session.engineType)}
{getStatusTag(session.status)}
<span>{formatTime(session.createdAt)}</span>
{session.lastActivity && (
<span>
{t("Claw.Sessions.lastActivity")}:{" "}
{formatTime(session.lastActivity)}
</span>
)}
</div>
</div>
<div className={styles.sessionActions}>
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => handleOpenSession(session.projectId || "")}
>
{t("Claw.Sessions.open")}
</Button>
<Button
size="small"
danger
icon={<StopOutlined />}
loading={isStopping}
onClick={() => handleStopSession(session.id)}
>
{t("Claw.Sessions.stop")}
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export default SessionsPage;

View File

@@ -0,0 +1,226 @@
import { useState, useEffect } from "react";
import {
agentRunnerManager,
AgentRunnerConfig,
AgentRunnerStatus,
} from "../../services/agents/agentRunner";
import { DEFAULT_ANTHROPIC_API_URL } from "@shared/constants";
import { t } from "../../services/core/i18n";
interface AgentRunnerSettingsProps {
isOpen: boolean;
onClose: () => void;
}
function AgentRunnerSettings({ isOpen, onClose }: AgentRunnerSettingsProps) {
const [config, setConfig] = useState<AgentRunnerConfig>(
agentRunnerManager.getConfig(),
);
const [status, setStatus] = useState<AgentRunnerStatus>({ running: false });
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
if (isOpen) {
loadConfig();
checkStatus();
}
}, [isOpen]);
const loadConfig = async () => {
await agentRunnerManager.loadConfig();
setConfig(agentRunnerManager.getConfig());
};
const checkStatus = async () => {
const s = await agentRunnerManager.checkStatus();
setStatus(s);
};
const handleSave = async () => {
agentRunnerManager.setConfig(config);
await agentRunnerManager.saveConfig();
setMessage(t("Claw.AgentRunner.configSaved"));
setTimeout(() => setMessage(""), 2000);
};
const handleStartStop = async () => {
setLoading(true);
setMessage("");
try {
if (status.running) {
const result = await agentRunnerManager.stop();
if (result.success) {
setMessage(t("Claw.AgentRunner.stopped"));
} else {
setMessage(t("Claw.AgentRunner.error", result.error || ""));
}
} else {
const result = await agentRunnerManager.start();
if (result.success) {
setMessage(t("Claw.AgentRunner.started"));
} else {
setMessage(t("Claw.AgentRunner.error", result.error || ""));
}
}
} catch (error) {
setMessage(t("Claw.AgentRunner.error", String(error)));
}
await checkStatus();
setLoading(false);
};
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content agent-runner-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>Agent Runner</h2>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
<div className="agent-runner-section">
<div className="status-panel">
<div
className={`status-indicator ${status.running ? "running" : "stopped"}`}
>
{status.running
? t("Claw.AgentRunner.running")
: t("Claw.AgentRunner.stoppedStatus")}
</div>
{status.pid && <div className="pid">PID: {status.pid}</div>}
</div>
{status.running && (
<div className="url-info">
<div className="url-item">
<span className="label">
{t("Claw.AgentRunner.backendAddress")}:
</span>
<code>{status.backendUrl}</code>
</div>
<div className="url-item">
<span className="label">
{t("Claw.AgentRunner.proxyAddress")}:
</span>
<code>{status.proxyUrl}</code>
</div>
</div>
)}
<button
className={`toggle-agent-btn ${status.running ? "stop" : "start"}`}
onClick={handleStartStop}
disabled={loading}
>
{loading
? "..."
: status.running
? t("Claw.AgentRunner.stop")
: t("Claw.AgentRunner.start")}
</button>
</div>
<div className="agent-runner-section">
<h3>{t("Claw.AgentRunner.config")}</h3>
<div className="form-group">
<label>{t("Claw.AgentRunner.executablePath")}</label>
<input
type="text"
value={config.binPath}
onChange={(e) =>
setConfig({ ...config, binPath: e.target.value })
}
placeholder="qiming-agent-core"
/>
</div>
<div className="form-row">
<div className="form-group">
<label>{t("Claw.AgentRunner.backendPort")}</label>
<input
type="number"
value={config.backendPort}
onChange={(e) =>
setConfig({
...config,
backendPort: parseInt(e.target.value),
})
}
placeholder="60001"
/>
</div>
<div className="form-group">
<label>{t("Claw.AgentRunner.proxyPort")}</label>
<input
type="number"
value={config.proxyPort}
onChange={(e) =>
setConfig({ ...config, proxyPort: parseInt(e.target.value) })
}
placeholder="60002"
/>
</div>
</div>
<div className="form-group">
<label>{t("Claw.AgentRunner.apiKey")}</label>
<input
type="password"
value={config.apiKey}
onChange={(e) => setConfig({ ...config, apiKey: e.target.value })}
placeholder="sk-ant-..."
/>
</div>
<div className="form-group">
<label>{t("Claw.AgentRunner.apiBaseUrl")}</label>
<input
type="text"
value={config.apiBaseUrl}
onChange={(e) =>
setConfig({ ...config, apiBaseUrl: e.target.value })
}
placeholder={DEFAULT_ANTHROPIC_API_URL}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="form-group">
<label>{t("Claw.AgentRunner.defaultModel")}</label>
<select
value={config.defaultModel}
onChange={(e) =>
setConfig({ ...config, defaultModel: e.target.value })
}
>
<option value="claude-opus-4-20250514">Claude Opus 4</option>
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
<option value="claude-haiku-3-20240307">Claude Haiku 3</option>
</select>
</div>
</div>
{message && <div className="agent-runner-message">{message}</div>}
<div className="modal-footer">
<button className="save-btn" onClick={handleSave}>
{t("Claw.AgentRunner.saveConfig")}
</button>
</div>
</div>
</div>
);
}
export default AgentRunnerSettings;

View File

@@ -0,0 +1,263 @@
import React, { useState, useEffect } from "react";
import {
Card,
Input,
Select,
Button,
Space,
Divider,
Typography,
Badge,
Form,
Switch,
message,
} from "antd";
import {
CloudServerOutlined,
PlayCircleOutlined,
StopOutlined,
SaveOutlined,
} from "@ant-design/icons";
import {
DEFAULT_ANTHROPIC_API_URL,
DEFAULT_AI_MODEL,
normalizeOptionalPort,
} from "@shared/constants";
import { aiService } from "../../services/core/ai";
import { t } from "../../services/core/i18n";
const { Title, Text } = Typography;
interface AgentSettingsProps {
isOpen: boolean;
onClose: () => void;
}
function AgentSettings({ isOpen, onClose }: AgentSettingsProps) {
const [agentType, setAgentType] = useState("claude-code");
const [binPath, setBinPath] = useState("claude");
const [backendPort, setBackendPort] = useState(60001);
const [apiKey, setApiKey] = useState("");
const [apiBaseUrl, setApiBaseUrl] = useState(DEFAULT_ANTHROPIC_API_URL);
const [model, setModel] = useState(DEFAULT_AI_MODEL);
const [running, setRunning] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen) {
loadConfig();
checkStatus();
}
}, [isOpen]);
const loadConfig = async () => {
try {
const saved = await window.electronAPI?.settings.get("agent_config");
if (saved) {
const config = saved as any;
setAgentType(config.type || "claude-code");
setBinPath(config.binPath || "claude");
setBackendPort(config.backendPort || 60001);
setApiKey(config.apiKey || "");
setApiBaseUrl(config.apiBaseUrl || DEFAULT_ANTHROPIC_API_URL);
setModel(config.model || DEFAULT_AI_MODEL);
}
} catch (error) {
console.error(t("Claw.Agent.loadConfigFailed"), error);
}
};
const checkStatus = async () => {
try {
const status = await window.electronAPI?.agent.serviceStatus();
setRunning(status?.running || false);
} catch (error) {
console.error(t("Claw.Agent.checkStatusFailed"), error);
}
};
const handleSave = async () => {
const config = {
type: agentType,
binPath,
backendPort,
apiKey,
apiBaseUrl,
model,
};
await window.electronAPI?.settings.set("agent_config", config);
message.success(t("Claw.Agent.configSaved"));
};
const handleStartStop = async () => {
setLoading(true);
try {
if (running) {
await window.electronAPI?.agent.destroy();
message.success(t("Claw.Agent.stopped"));
} else {
const step1 = (await window.electronAPI?.settings.get(
"step1_config",
)) as { workspaceDir?: string } | null;
const result = await window.electronAPI?.agent.init({
engine: agentType === "claude-code" ? "claude-code" : "qimingcode",
apiKey,
baseUrl: apiBaseUrl,
model,
workspaceDir: step1?.workspaceDir || "",
port: normalizeOptionalPort(backendPort),
engineBinaryPath: binPath || undefined,
});
if (result?.success) {
message.success(t("Claw.Agent.started"));
} else {
message.error(
t("Claw.Agent.startFailedWithReason", {
reason: result?.error ?? "",
}),
);
}
}
} catch (error) {
message.error(
t("Claw.Agent.operationError", {
message: error instanceof Error ? error.message : String(error),
}),
);
}
await checkStatus();
setLoading(false);
};
if (!isOpen) return null;
return (
<Card
title={
<Space>
<CloudServerOutlined />
{t("Claw.Agent.engineSettings")}
</Space>
}
style={{ margin: 16 }}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
{/* Status Panel */}
<Card size="small" style={{ background: "#f5f5f5" }}>
<Space>
<Badge
status={running ? "success" : "default"}
text={
running
? t("Claw.Agent.running")
: t("Claw.Agent.stoppedStatus")
}
/>
<Button
type={running ? "default" : "primary"}
icon={running ? <StopOutlined /> : <PlayCircleOutlined />}
danger={running}
onClick={handleStartStop}
loading={loading}
>
{running ? t("Claw.Agent.stop") : t("Claw.Agent.start")}
</Button>
</Space>
</Card>
<Divider orientation="left">{t("Claw.Agent.engineType")}</Divider>
<Form layout="vertical">
<Form.Item label={t("Claw.Agent.type")}>
<Select
value={agentType}
onChange={(v) => {
setAgentType(v);
setBinPath(v === "claude-code" ? "claude-code" : "qimingcode");
}}
>
<Select.Option value="claude-code">
<Space>
<span>Claude Code (ACP)</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{t("Claw.Agent.claudeCodeAcpDesc")}
</Text>
</Space>
</Select.Option>
<Select.Option value="qimingcode">
<Space>
<span>qimingcode (ACP)</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{t("Claw.Agent.qimingcodeDesc")}
</Text>
</Space>
</Select.Option>
</Select>
</Form.Item>
<Divider orientation="left">{t("Claw.Agent.portConfig")}</Divider>
<Form.Item label={t("Claw.Agent.backendPort")}>
<Input
type="number"
value={backendPort}
onChange={(e) => setBackendPort(parseInt(e.target.value))}
placeholder="60001"
/>
<Text type="secondary">{t("Claw.Agent.backendPortHint")}</Text>
</Form.Item>
<Divider orientation="left">{t("Claw.Agent.apiConfig")}</Divider>
<Form.Item label={t("Claw.Agent.executablePath")}>
<Input
value={binPath}
onChange={(e) => setBinPath(e.target.value)}
placeholder={
agentType === "qimingcode" ? "qimingcode" : "claude-code-acp-ts"
}
/>
</Form.Item>
<Form.Item label={t("Claw.Agent.apiKey")}>
<Input.Password
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-ant-..."
/>
</Form.Item>
<Form.Item label={t("Claw.Agent.apiBaseUrl")}>
<Input
value={apiBaseUrl}
onChange={(e) => setApiBaseUrl(e.target.value)}
placeholder={DEFAULT_ANTHROPIC_API_URL}
autoComplete="off"
spellCheck={false}
/>
</Form.Item>
<Form.Item label={t("Claw.Agent.model")}>
<Select value={model} onChange={setModel}>
<Select.Option value="claude-opus-4-20250514">
Claude Opus 4
</Select.Option>
<Select.Option value="claude-sonnet-4-20250514">
Claude Sonnet 4
</Select.Option>
<Select.Option value="claude-haiku-3-20240307">
Claude Haiku 3
</Select.Option>
</Select>
</Form.Item>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
{t("Claw.Agent.saveConfig")}
</Button>
</Form>
</Space>
</Card>
);
}
export default AgentSettings;

View File

@@ -0,0 +1,701 @@
/**
* GUI Agent 设置组件
*
* 配置视觉模型、目标显示器、坐标模式等 GUI Agent 参数。
* 通过 IPC settings API 读写 SQLite 中的 gui_agent_vision_model 配置。
*
* 特性:
* - 区分 Anthropic 协议和 OpenAI 协议
* - 支持预设提供商 (Anthropic/OpenAI/Google) 和国内提供商 (智谱/通义/DeepSeek/MiniMax)
* - 支持完全自定义提供商自定义名称、Base URL、模型 ID
* - 坐标模式默认"自动",根据模型名自动匹配最佳坐标系
* - UI 字段与 server 端 GuiAgentConfig 一一对应
*/
import React, { useState, useEffect, useMemo } from "react";
import {
Form,
Input,
InputNumber,
Select,
Button,
message,
Spin,
Row,
Col,
Tooltip,
} from "antd";
import {
SaveOutlined,
EditOutlined,
QuestionCircleOutlined,
} from "@ant-design/icons";
import type {
GuiVisionModelConfig,
GuiDisplayInfo,
} from "@shared/types/computerTypes";
import { t } from "../../services/core/i18n";
import { useI18nLang } from "../../App";
const DEFAULT_CONFIG: GuiVisionModelConfig = {
provider: "anthropic",
apiProtocol: "anthropic",
model: "claude-sonnet-4-20250514",
displayIndex: 0,
coordinateMode: "auto",
maxSteps: 50,
stepDelayMs: 1500,
jpegQuality: 75,
};
// 预设提供商列表(含协议和默认 Base URL
interface PresetProvider {
value: string;
label: string;
protocol: "anthropic" | "openai";
baseUrl?: string;
}
/** 特殊值:用户选择"自定义"时的 Select value */
const CUSTOM_PROVIDER_VALUE = "__custom__";
// 预设模型列表(按提供商分组)
const PRESET_MODELS: Record<string, Array<{ value: string; label: string }>> = {
anthropic: [
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
],
openai: [
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o Mini" },
{ value: "gpt-5", label: "GPT-5" },
],
google: [
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "gemini-2.5-pro-preview-06-05", label: "Gemini 2.5 Pro" },
],
zhipu: [
{ value: "glm-4v-plus", label: "GLM-4V Plus" },
{ value: "glm-4v", label: "GLM-4V" },
],
qwen: [
{ value: "qwen-vl-max", label: "Qwen-VL Max" },
{ value: "qwen2.5-vl-72b-instruct", label: "Qwen2.5-VL 72B" },
],
deepseek: [{ value: "deepseek-chat", label: "DeepSeek Chat" }],
minimax: [{ value: "MiniMax-VL-01", label: "MiniMax-VL-01" }],
};
/**
* 根据模型名推断坐标模式(镜像 server 端 modelProfiles.ts 的逻辑)
*/
function inferCoordinateMode(modelName: string): string {
if (/^claude-/i.test(modelName)) return "image-absolute";
if (/^gpt-(4o|5)/i.test(modelName)) return "image-absolute";
if (/^gemini/i.test(modelName)) return "normalized-999";
if (/^ui-tars/i.test(modelName)) return "normalized-1000";
if (/^qwen(2\.5)?-vl/i.test(modelName)) return "image-absolute";
if (/^cogagent/i.test(modelName)) return "image-absolute";
if (/^(seeclick|showui)/i.test(modelName)) return "normalized-0-1";
if (/^glm-4v/i.test(modelName)) return "image-absolute";
return "image-absolute"; // fallback
}
interface GUIAgentSettingsProps {
isOpen: boolean;
onClose: () => void;
}
function GUIAgentSettings({ isOpen, onClose }: GUIAgentSettingsProps) {
const [form] = Form.useForm<GuiVisionModelConfig>();
const { lang } = useI18nLang();
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [displays, setDisplays] = useState<GuiDisplayInfo[]>([]);
const [originalConfig, setOriginalConfig] =
useState<GuiVisionModelConfig>(DEFAULT_CONFIG);
const [selectedProvider, setSelectedProvider] = useState("anthropic");
const [customProviderName, setCustomProviderName] = useState("");
const [selectedModel, setSelectedModel] = useState(
"claude-sonnet-4-20250514",
);
const [selectedCoordinateMode, setSelectedCoordinateMode] = useState("auto");
// ========== i18n 翻译数组(组件内 useMemo避免顶层 t() 时序问题) ==========
const PRESET_PROVIDERS = useMemo<PresetProvider[]>(
() => [
{ value: "anthropic", label: "Anthropic", protocol: "anthropic" },
{ value: "openai", label: "OpenAI", protocol: "openai" },
{ value: "google", label: "Google", protocol: "openai" },
{
value: "zhipu",
label: t("Claw.GUIAgent.provider.zhipu"),
protocol: "openai",
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
},
{
value: "qwen",
label: t("Claw.GUIAgent.provider.qwen"),
protocol: "openai",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{
value: "deepseek",
label: "DeepSeek",
protocol: "openai",
baseUrl: "https://api.deepseek.com/v1",
},
{
value: "minimax",
label: "MiniMax",
protocol: "openai",
baseUrl: "https://api.minimax.chat/v1",
},
],
[lang],
);
const PROVIDER_SELECT_OPTIONS = useMemo(
() => [
...PRESET_PROVIDERS.map((p) => ({ value: p.value, label: p.label })),
{
value: CUSTOM_PROVIDER_VALUE,
label: t("Claw.GUIAgent.provider.custom"),
},
],
[PRESET_PROVIDERS],
);
const COORDINATE_MODE_OPTIONS = useMemo(
() => [
{ value: "auto", label: t("Claw.GUIAgent.coordinateMode.auto") },
{
value: "image-absolute",
label: t("Claw.GUIAgent.coordinateMode.imageAbsolute"),
},
{
value: "normalized-1000",
label: t("Claw.GUIAgent.coordinateMode.normalized1000"),
},
{
value: "normalized-999",
label: t("Claw.GUIAgent.coordinateMode.normalized999"),
},
{
value: "normalized-0-1",
label: t("Claw.GUIAgent.coordinateMode.normalized0to1"),
},
],
[lang],
);
const API_PROTOCOL_OPTIONS = useMemo(
() => [
{ value: "anthropic", label: t("Claw.GUIAgent.protocol.anthropic") },
{ value: "openai", label: t("Claw.GUIAgent.protocol.openai") },
],
[lang],
);
const coordinateModeLabel = (mode: string): string => {
const found = COORDINATE_MODE_OPTIONS.find((o) => o.value === mode);
return found ? found.label : mode;
};
const findPresetProvider = (value: string): PresetProvider | undefined => {
return PRESET_PROVIDERS.find((p) => p.value === value);
};
// ========== 状态 ==========
// 当前是否处于自定义提供商模式
const isCustomProvider = useMemo(
() => selectedProvider === CUSTOM_PROVIDER_VALUE,
[selectedProvider],
);
// 实际的 provider 值(保存到配置中的值)
const effectiveProvider = useMemo(
() => (isCustomProvider ? customProviderName : selectedProvider),
[isCustomProvider, customProviderName, selectedProvider],
);
// 当前是否有预设模型列表
const hasPresetModels = useMemo(
() =>
!isCustomProvider && (PRESET_MODELS[selectedProvider]?.length ?? 0) > 0,
[selectedProvider, isCustomProvider],
);
// 当前模型推断出的坐标模式
const inferredMode = useMemo(
() => inferCoordinateMode(selectedModel),
[selectedModel],
);
useEffect(() => {
if (isOpen) {
loadConfig();
loadDisplays();
}
}, [isOpen]);
const loadConfig = async () => {
setLoading(true);
try {
const saved = (await window.electronAPI?.settings.get(
"gui_agent_vision_model",
)) as GuiVisionModelConfig | null;
const config = { ...DEFAULT_CONFIG, ...saved };
form.setFieldsValue(config);
setOriginalConfig(config);
// 判断保存的 provider 是否是预设提供商
const isPreset = PRESET_PROVIDERS.some(
(p) => p.value === config.provider,
);
if (isPreset) {
setSelectedProvider(config.provider);
setCustomProviderName("");
} else {
// 非预设 → 进入自定义模式,恢复自定义名称
setSelectedProvider(CUSTOM_PROVIDER_VALUE);
setCustomProviderName(config.provider);
}
setSelectedModel(config.model);
setSelectedCoordinateMode(config.coordinateMode);
} catch (error) {
console.error("Failed to load GUI Agent config:", error);
} finally {
setLoading(false);
}
};
const loadDisplays = async () => {
try {
const port =
(
(await window.electronAPI?.settings.get("step1_config")) as {
agentPort?: number;
} | null
)?.agentPort || 60001;
const resp = await fetch(
`http://127.0.0.1:${port}/computer/gui-agent/displays`,
);
const result = await resp.json();
if (result.success && result.data) {
setDisplays(result.data);
}
} catch {
setDisplays([
{
index: 0,
label: t("Claw.GUIAgent.display.primary"),
width: window.screen.width,
height: window.screen.height,
scaleFactor: window.devicePixelRatio || 1,
isPrimary: true,
},
]);
}
};
const handleCancel = () => {
// resetFields 清除校验错误状态(红框、提示),然后恢复原始值
form.resetFields();
form.setFieldsValue(originalConfig);
const isPreset = PRESET_PROVIDERS.some(
(p) => p.value === originalConfig.provider,
);
if (isPreset) {
setSelectedProvider(originalConfig.provider);
setCustomProviderName("");
} else {
setSelectedProvider(CUSTOM_PROVIDER_VALUE);
setCustomProviderName(originalConfig.provider);
}
setSelectedModel(originalConfig.model);
setSelectedCoordinateMode(originalConfig.coordinateMode);
setEditing(false);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
// 自定义模式下,用实际输入的 provider 名称替换 __custom__ 占位值
if (isCustomProvider) {
if (!customProviderName.trim()) {
message.error(t("Claw.GUIAgent.error.customProviderNameRequired"));
return;
}
values.provider = customProviderName.trim();
}
setSaving(true);
try {
await window.electronAPI?.settings.set(
"gui_agent_vision_model",
values,
);
setOriginalConfig(values);
setEditing(false);
message.success(t("Claw.GUIAgent.message.configSaved"));
} catch (error) {
message.error(t("Claw.GUIAgent.message.saveFailed"));
} finally {
setSaving(false);
}
} catch {
// form validation failed
}
};
const handleProviderChange = (provider: string) => {
setSelectedProvider(provider);
if (provider === CUSTOM_PROVIDER_VALUE) {
// 自定义模式:清空自定义名称、默认 OpenAI 协议、清空 baseUrl 和模型
setCustomProviderName("");
form.setFieldValue("provider", CUSTOM_PROVIDER_VALUE);
form.setFieldValue("apiProtocol", "openai");
form.setFieldValue("baseUrl", undefined);
form.setFieldValue("model", "");
setSelectedModel("");
return;
}
const preset = findPresetProvider(provider);
// 自动设置 API 协议
if (preset) {
form.setFieldValue("apiProtocol", preset.protocol);
// 预设 baseUrl 自动填充
form.setFieldValue("baseUrl", preset.baseUrl || undefined);
} else {
// 自定义提供商默认 OpenAI 协议
form.setFieldValue("apiProtocol", "openai");
form.setFieldValue("baseUrl", undefined);
}
// 预设提供商:自动选择第一个模型
const models = PRESET_MODELS[provider];
if (models && models.length > 0) {
form.setFieldValue("model", models[0].value);
setSelectedModel(models[0].value);
} else {
// 清空模型让用户输入
form.setFieldValue("model", "");
setSelectedModel("");
}
};
const handleModelChange = (model: string) => {
setSelectedModel(model);
};
const handleCoordinateModeChange = (mode: string) => {
setSelectedCoordinateMode(mode);
};
if (!isOpen) return null;
if (loading) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin size="small" />
</div>
);
}
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<span style={{ fontSize: 13, fontWeight: 500 }}>
{t("Claw.GUIAgent.title")}
</span>
{editing ? (
<div style={{ display: "flex", gap: 6 }}>
<Button size="small" onClick={handleCancel} disabled={saving}>
{t("Claw.Common.cancel")}
</Button>
<Button
size="small"
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
>
{t("Claw.Common.save")}
</Button>
</div>
) : (
<Button
size="small"
icon={<EditOutlined />}
onClick={() => setEditing(true)}
>
{t("Claw.Common.edit")}
</Button>
)}
</div>
<Form form={form} layout="vertical" disabled={!editing} size="small">
{/* Row 1: 提供商 + API 协议 */}
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="provider"
label={t("Claw.GUIAgent.form.provider")}
rules={[
{
required: true,
message: t("Claw.GUIAgent.error.providerRequired"),
},
]}
>
<Select
allowClear={false}
options={PROVIDER_SELECT_OPTIONS}
onChange={handleProviderChange}
placeholder={t("Claw.GUIAgent.placeholder.selectProvider")}
/>
</Form.Item>
{/* 自定义模式下显示名称输入框 */}
{isCustomProvider && (
<Form.Item
label={t("Claw.GUIAgent.form.customProviderName")}
rules={[
{
required: true,
message: t("Claw.GUIAgent.error.providerNameRequired"),
},
]}
style={{ marginTop: -8 }}
>
<Input
value={customProviderName}
onChange={(e) => setCustomProviderName(e.target.value)}
placeholder={t(
"Claw.GUIAgent.placeholder.customProviderName",
)}
/>
</Form.Item>
)}
</Col>
<Col span={12}>
<Form.Item
name="apiProtocol"
label={
<span>
{t("Claw.GUIAgent.form.apiProtocol")}{" "}
<Tooltip title={t("Claw.GUIAgent.tooltip.apiProtocol")}>
<QuestionCircleOutlined
style={{ color: "var(--color-text-tertiary)" }}
/>
</Tooltip>
</span>
}
rules={[{ required: true }]}
>
<Select options={API_PROTOCOL_OPTIONS} />
</Form.Item>
</Col>
</Row>
{/* Row 2: 模型 + Base URL */}
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="model"
label={t("Claw.GUIAgent.form.visionModel")}
rules={[
{
required: true,
message: t("Claw.GUIAgent.error.modelRequired"),
},
]}
>
{hasPresetModels ? (
<Select
showSearch
options={PRESET_MODELS[selectedProvider] || []}
onChange={handleModelChange}
placeholder={t("Claw.GUIAgent.placeholder.selectModel")}
filterOption={(input, option) =>
(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase()) ||
(option?.value ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
/>
) : (
<Input
placeholder={t("Claw.GUIAgent.placeholder.modelId")}
onChange={(e) => handleModelChange(e.target.value)}
/>
)}
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="baseUrl"
label={
<span>
{t("Claw.GUIAgent.form.baseUrl")}{" "}
<Tooltip title={t("Claw.GUIAgent.tooltip.baseUrl")}>
<QuestionCircleOutlined
style={{ color: "var(--color-text-tertiary)" }}
/>
</Tooltip>
</span>
}
rules={
isCustomProvider
? [
{
required: true,
message: t("Claw.GUIAgent.error.baseUrlRequired"),
},
]
: []
}
>
<Input placeholder={t("Claw.GUIAgent.placeholder.baseUrl")} />
</Form.Item>
</Col>
</Row>
{/* Row 3: API Key + 显示器 */}
<Row gutter={16}>
<Col span={12}>
<Form.Item name="apiKey" label={t("Claw.GUIAgent.form.apiKey")}>
<Input.Password
placeholder={t("Claw.GUIAgent.placeholder.apiKey")}
visibilityToggle
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="displayIndex"
label={t("Claw.GUIAgent.form.targetDisplay")}
rules={[{ required: true }]}
>
<Select>
{displays.map((d) => (
<Select.Option key={d.index} value={d.index}>
{d.label}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
{/* Row 4: 坐标模式 */}
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="coordinateMode"
label={
<span>
{t("Claw.GUIAgent.form.coordinateMode")}{" "}
<Tooltip
title={
selectedCoordinateMode === "auto"
? t(
"Claw.GUIAgent.tooltip.coordinateModeAuto",
selectedModel,
coordinateModeLabel(inferredMode),
)
: t("Claw.GUIAgent.tooltip.coordinateModeManual")
}
>
<QuestionCircleOutlined
style={{ color: "var(--color-text-tertiary)" }}
/>
</Tooltip>
</span>
}
rules={[{ required: true }]}
>
<Select
options={COORDINATE_MODE_OPTIONS}
onChange={handleCoordinateModeChange}
/>
</Form.Item>
</Col>
<Col span={12} />
</Row>
{/* 自动模式下显示推断结果提示 */}
{selectedCoordinateMode === "auto" && selectedModel && (
<div
style={{
marginTop: -8,
marginBottom: 12,
fontSize: 12,
color: "var(--color-text-tertiary)",
}}
>
{t("Claw.GUIAgent.display.autoMatch")}:{" "}
{coordinateModeLabel(inferredMode)}
</div>
)}
{/* Row 5: 数值参数 */}
<Row gutter={16}>
<Col span={8}>
<Form.Item name="maxSteps" label={t("Claw.GUIAgent.form.maxSteps")}>
<InputNumber min={1} max={200} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="stepDelayMs"
label={t("Claw.GUIAgent.form.stepDelay")}
>
<InputNumber
min={100}
max={30000}
step={100}
style={{ width: "100%" }}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="jpegQuality"
label={t("Claw.GUIAgent.form.jpegQuality")}
>
<InputNumber min={1} max={100} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
</Form>
{!editing && (
<div
style={{
marginTop: 8,
fontSize: 12,
color: "var(--color-text-tertiary)",
}}
>
{t("Claw.GUIAgent.description")}
</div>
)}
</div>
);
}
export default GUIAgentSettings;

View File

@@ -0,0 +1,327 @@
import { useState, useEffect } from "react";
import {
imService,
IMPlatform,
IMConfig,
} from "../../services/integrations/im";
import { t } from "../../services/core/i18n";
interface IMSettingsProps {
isOpen: boolean;
onClose: () => void;
}
const defaultConfigs: Record<IMPlatform, IMConfig> = {
discord: { platform: "discord", enabled: false, token: "", allowedUsers: [] },
telegram: {
platform: "telegram",
enabled: false,
botToken: "",
allowedUsers: [],
},
dingtalk: {
platform: "dingtalk",
enabled: false,
appKey: "",
appSecret: "",
allowedUsers: [],
},
feishu: {
platform: "feishu",
enabled: false,
appId: "",
appSecret: "",
allowedUsers: [],
},
};
const PLATFORM_LABELS: Record<IMPlatform, string> = {
discord: "Discord",
telegram: "Telegram",
dingtalk: t("Claw.IMSettings.platform.dingtalk"),
feishu: t("Claw.IMSettings.platform.feishu"),
};
function IMSettings({ isOpen, onClose }: IMSettingsProps) {
const [activeTab, setActiveTab] = useState<IMPlatform>("discord");
const [configs, setConfigs] =
useState<Record<IMPlatform, IMConfig>>(defaultConfigs);
const [connecting, setConnecting] = useState(false);
const [status, setStatus] = useState<Record<IMPlatform, boolean>>({
discord: false,
telegram: false,
dingtalk: false,
feishu: false,
});
const [message, setMessage] = useState("");
useEffect(() => {
if (isOpen) {
loadConfigs();
}
}, [isOpen]);
const loadConfigs = async () => {
await imService.loadConfigs();
const platforms: IMPlatform[] = [
"discord",
"telegram",
"dingtalk",
"feishu",
];
const newConfigs = { ...defaultConfigs };
const newStatus = { ...status };
for (const platform of platforms) {
const config = imService.getConfig(platform);
if (config) {
newConfigs[platform] = config;
}
newStatus[platform] = imService.isConnected(platform);
}
setConfigs(newConfigs);
setStatus(newStatus);
};
const handleSave = async () => {
for (const config of Object.values(configs)) {
imService.setConfig(config.platform, config);
}
await imService.saveConfigs();
setMessage(t("Claw.IMSettings.configSaved"));
setTimeout(() => setMessage(""), 2000);
};
const handleConnect = async () => {
setConnecting(true);
setMessage("");
const result = await imService.connect(activeTab);
if (result.success) {
setStatus({ ...status, [activeTab]: true });
setMessage(t("Claw.IMSettings.connected", PLATFORM_LABELS[activeTab]));
} else {
setMessage(t("Claw.IMSettings.error", result.error));
}
setConnecting(false);
};
const handleDisconnect = async () => {
await imService.disconnect(activeTab);
setStatus({ ...status, [activeTab]: false });
setMessage(t("Claw.IMSettings.disconnected"));
};
const updateConfig = (key: keyof IMConfig, value: unknown) => {
setConfigs({
...configs,
[activeTab]: { ...configs[activeTab], [key]: value },
});
};
if (!isOpen) return null;
const currentConfig = configs[activeTab];
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content im-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>{t("Claw.IMSettings.title")}</h2>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
<div className="im-tabs">
{(["discord", "telegram", "dingtalk", "feishu"] as IMPlatform[]).map(
(platform) => (
<button
key={platform}
className={`im-tab ${activeTab === platform ? "active" : ""} ${status[platform] ? "connected" : ""}`}
onClick={() => setActiveTab(platform)}
>
<span className="tab-icon">
{platform === "discord" && "💬"}
{platform === "telegram" && "✈️"}
{platform === "dingtalk" && "📎"}
{platform === "feishu" && "📝"}
</span>
<span className="tab-name">{PLATFORM_LABELS[platform]}</span>
{status[platform] && <span className="tab-status"></span>}
</button>
),
)}
</div>
<div className="im-content">
<div className="im-section">
<label className="toggle-label">
<span>
{t("Claw.IMSettings.enable", PLATFORM_LABELS[activeTab])}
</span>
<input
type="checkbox"
checked={currentConfig.enabled}
onChange={(e) => updateConfig("enabled", e.target.checked)}
/>
</label>
</div>
{activeTab === "discord" && (
<div className="im-section">
<h3>{t("Claw.IMSettings.botConfig")}</h3>
<div className="form-group">
<label>Bot Token</label>
<input
type="password"
value={currentConfig.botToken || ""}
onChange={(e) => updateConfig("botToken", e.target.value)}
placeholder="MTEw..."
/>
</div>
<div className="form-group">
<label>{t("Claw.IMSettings.allowedUsers")}</label>
<input
type="text"
value={currentConfig.allowedUsers?.join(", ") || ""}
onChange={(e) =>
updateConfig(
"allowedUsers",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="123456789, 987654321"
/>
</div>
</div>
)}
{activeTab === "telegram" && (
<div className="im-section">
<h3>{t("Claw.IMSettings.botConfig")}</h3>
<div className="form-group">
<label>Bot Token</label>
<input
type="password"
value={currentConfig.botToken || ""}
onChange={(e) => updateConfig("botToken", e.target.value)}
placeholder="123456789:ABC..."
/>
</div>
<div className="form-group">
<label>{t("Claw.IMSettings.allowedUsers")}</label>
<input
type="text"
value={currentConfig.allowedUsers?.join(", ") || ""}
onChange={(e) =>
updateConfig(
"allowedUsers",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="123456789"
/>
</div>
</div>
)}
{activeTab === "dingtalk" && (
<div className="im-section">
<h3>{t("Claw.IMSettings.appConfig")}</h3>
<div className="form-group">
<label>App Key</label>
<input
type="text"
value={currentConfig.appKey || ""}
onChange={(e) => updateConfig("appKey", e.target.value)}
placeholder="ding..."
/>
</div>
<div className="form-group">
<label>App Secret</label>
<input
type="password"
value={currentConfig.appSecret || ""}
onChange={(e) => updateConfig("appSecret", e.target.value)}
/>
</div>
</div>
)}
{activeTab === "feishu" && (
<div className="im-section">
<h3>{t("Claw.IMSettings.appConfig")}</h3>
<div className="form-group">
<label>App ID</label>
<input
type="text"
value={currentConfig.appId || ""}
onChange={(e) => updateConfig("appId", e.target.value)}
placeholder="cli_..."
/>
</div>
<div className="form-group">
<label>App Secret</label>
<input
type="password"
value={currentConfig.appSecret || ""}
onChange={(e) => updateConfig("appSecret", e.target.value)}
/>
</div>
</div>
)}
<div className="im-section">
<h3>{t("Claw.IMSettings.options")}</h3>
<label className="toggle-label">
<span>{t("Claw.IMSettings.autoReply")}</span>
<input
type="checkbox"
checked={currentConfig.autoReply || false}
onChange={(e) => updateConfig("autoReply", e.target.checked)}
/>
</label>
</div>
</div>
{message && <div className="im-message">{message}</div>}
<div className="im-actions">
{status[activeTab] ? (
<button className="disconnect-btn" onClick={handleDisconnect}>
{t("Claw.IMSettings.disconnect")}
</button>
) : (
<button
className="connect-btn"
onClick={handleConnect}
disabled={connecting || !currentConfig.enabled}
>
{connecting
? t("Claw.IMSettings.connecting")
: t("Claw.IMSettings.connect")}
</button>
)}
<button className="save-btn" onClick={handleSave}>
{t("Claw.IMSettings.saveConfig")}
</button>
</div>
</div>
</div>
);
}
export default IMSettings;

View File

@@ -0,0 +1,218 @@
import { useState, useEffect } from "react";
import {
lanproxyManager,
LanproxyConfig,
LanproxyStatus,
} from "../../services/integrations/lanproxy";
import { LOCALHOST_IP, DEFAULT_LANPROXY_PORT } from "@shared/constants";
import { t } from "../../services/core/i18n";
interface LanproxySettingsProps {
isOpen: boolean;
onClose: () => void;
}
function maskKey(key: string): string {
if (key.length > 8) {
return `${key.slice(0, 4)}****${key.slice(-4)}`;
}
return key ? "****" : t("Claw.Lanproxy.notLoggedIn");
}
function LanproxySettings({ isOpen, onClose }: LanproxySettingsProps) {
const [config, setConfig] = useState<LanproxyConfig>(
lanproxyManager.getConfig(),
);
const [status, setStatus] = useState<LanproxyStatus>({ running: false });
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [maskedClientKey, setMaskedClientKey] = useState("");
/** 当前平台是否有 lanproxy 二进制(无则显示「当前平台暂不支持」并禁用启动) */
const [binaryAvailable, setBinaryAvailable] = useState(true);
useEffect(() => {
if (isOpen) {
loadConfig();
checkStatus();
loadClientKey();
window.electronAPI?.lanproxy
.isAvailable?.()
.then((r) => setBinaryAvailable(r?.available ?? false))
.catch(() => setBinaryAvailable(false));
}
}, [isOpen]);
const loadConfig = async () => {
await lanproxyManager.loadConfig();
setConfig(lanproxyManager.getConfig());
};
const loadClientKey = async () => {
const key = (await window.electronAPI?.settings.get("auth.saved_key")) as
| string
| null;
setMaskedClientKey(maskKey(key || ""));
};
const checkStatus = async () => {
const s = await lanproxyManager.checkStatus();
setStatus(s);
};
const handleSave = async () => {
lanproxyManager.setConfig(config);
await lanproxyManager.saveConfig();
setMessage(t("Claw.Lanproxy.configSaved"));
setTimeout(() => setMessage(""), 2000);
};
const handleStartStop = async () => {
setLoading(true);
setMessage("");
try {
if (status.running) {
const result = await lanproxyManager.stop();
if (result.success) {
setMessage(t("Claw.Lanproxy.tunnelStopped"));
} else {
setMessage(t("Claw.Lanproxy.errorWithDetail", result.error || ""));
}
} else {
const result = await lanproxyManager.start();
if (result.success) {
setMessage(t("Claw.Lanproxy.tunnelStarted"));
} else {
setMessage(t("Claw.Lanproxy.errorWithDetail", result.error || ""));
}
}
} catch (error) {
setMessage(t("Claw.Lanproxy.errorWithDetail", String(error)));
}
await checkStatus();
setLoading(false);
};
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content lanproxy-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>{t("Claw.Lanproxy.title")}</h2>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
{!binaryAvailable && (
<div
className="lanproxy-unavailable"
style={{
padding: "10px 14px",
margin: "0 14px 12px",
background: "#fff7e6",
border: "1px solid #ffd591",
borderRadius: 6,
}}
>
{t("Claw.Lanproxy.platformNotSupported")}
</div>
)}
<div className="lanproxy-section">
<div className="status-panel">
<div
className={`status-indicator ${status.running ? "running" : "stopped"}`}
>
{status.running
? t("Claw.Lanproxy.running")
: t("Claw.Lanproxy.stopped")}
</div>
{status.pid && <div className="pid">PID: {status.pid}</div>}
</div>
<button
className={`toggle-lanproxy-btn ${status.running ? "stop" : "start"}`}
onClick={handleStartStop}
disabled={loading || (!binaryAvailable && !status.running)}
>
{loading
? "..."
: status.running
? t("Claw.Lanproxy.stop")
: t("Claw.Lanproxy.start")}
</button>
</div>
<div className="lanproxy-section">
<h3>{t("Claw.Lanproxy.config")}</h3>
<div className="form-row">
<div className="form-group">
<label>{t("Claw.Lanproxy.serverIp")}</label>
<input
type="text"
value={config.serverIp}
onChange={(e) =>
setConfig({ ...config, serverIp: e.target.value })
}
placeholder={LOCALHOST_IP}
/>
</div>
<div className="form-group">
<label>{t("Claw.Lanproxy.serverPort")}</label>
<input
type="number"
value={config.serverPort}
onChange={(e) =>
setConfig({ ...config, serverPort: parseInt(e.target.value) })
}
placeholder={String(DEFAULT_LANPROXY_PORT)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>{t("Claw.Lanproxy.clientKey")}</label>
<input
type="text"
value={maskedClientKey}
readOnly
disabled
style={{ opacity: 0.7, cursor: "not-allowed" }}
/>
</div>
<div className="form-group">
<label>{t("Claw.Lanproxy.ssl")}</label>
<select
value={config.ssl ? "true" : "false"}
onChange={(e) =>
setConfig({ ...config, ssl: e.target.value === "true" })
}
>
<option value="true">{t("Claw.Lanproxy.enable")}</option>
<option value="false">{t("Claw.Lanproxy.disable")}</option>
</select>
</div>
</div>
</div>
{message && <div className="lanproxy-message">{message}</div>}
<div className="modal-footer">
<button className="save-btn" onClick={handleSave}>
{t("Claw.Lanproxy.saveConfig")}
</button>
</div>
</div>
</div>
);
}
export default LanproxySettings;

View File

@@ -0,0 +1,475 @@
import { useState, useEffect, useCallback } from "react";
import {
Button,
Space,
Typography,
Input,
Segmented,
message,
Card,
Select,
} from "antd";
import {
ArrowLeftOutlined,
SaveOutlined,
CheckCircleOutlined,
} from "@ant-design/icons";
import Editor from "@monaco-editor/react";
import type { McpServerEntry, McpServersConfig } from "@shared/types/electron";
import { t } from "../../services/core/i18n";
const { Text } = Typography;
interface MCPServerEditorProps {
mode: "create" | "edit";
editingServerId?: string;
initialEntry?: McpServerEntry;
existingServerIds: string[];
isDarkMode: boolean;
fullConfig?: McpServersConfig;
onSave: (serverId: string, entry: McpServerEntry) => void;
onBack: () => void;
}
function serializeEntryToJson(serverId: string, entry: McpServerEntry): string {
const obj: Record<string, unknown> = {};
obj[serverId] = entry;
return JSON.stringify(obj, null, 2);
}
function parseServerFromJson(
text: string,
):
| { ok: true; serverId: string; entry: McpServerEntry }
| { ok: false; error: string } {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: t("Claw.MCP.message.invalidJson") };
}
const obj = parsed as Record<string, unknown>;
// 格式 B: {"mcpServers": {"server-name": {...}}}
if (
obj.mcpServers &&
typeof obj.mcpServers === "object" &&
!Array.isArray(obj.mcpServers)
) {
const servers = obj.mcpServers as Record<string, unknown>;
const keys = Object.keys(servers);
for (const key of keys) {
const val = servers[key];
if (
val &&
typeof val === "object" &&
("command" in val || "url" in val)
) {
return { ok: true, serverId: key, entry: val as McpServerEntry };
}
}
}
// 格式 A: {"server-name": {command/url entry}}
const keys = Object.keys(obj);
for (const key of keys) {
if (key === "mcpServers" || key === "allowTools" || key === "denyTools")
continue;
const val = obj[key];
if (val && typeof val === "object" && ("command" in val || "url" in val)) {
return { ok: true, serverId: key, entry: val as McpServerEntry };
}
}
return { ok: false, error: t("Claw.MCP.message.invalidJson") };
}
function MCPServerEditor({
mode,
editingServerId,
initialEntry,
existingServerIds,
isDarkMode,
fullConfig,
onSave,
onBack,
}: MCPServerEditorProps) {
const [editorTab, setEditorTab] = useState<"form" | "json">("form");
const [serverType, setServerType] = useState<"stdio" | "remote">("stdio");
const [serverId, setServerId] = useState("");
const [command, setCommand] = useState("");
const [argsText, setArgsText] = useState("");
const [url, setUrl] = useState("");
const [transport, setTransport] = useState<"streamable-http" | "sse">(
"streamable-http",
);
const [jsonText, setJsonText] = useState("");
const [jsonError, setJsonError] = useState("");
const [testLoading, setTestLoading] = useState(false);
const isEdit = mode === "edit";
useEffect(() => {
if (isEdit && initialEntry && editingServerId) {
if ("command" in initialEntry) {
setServerType("stdio");
setCommand(initialEntry.command);
setArgsText(JSON.stringify(initialEntry.args ?? []));
} else {
setServerType("remote");
setUrl(initialEntry.url);
setTransport(initialEntry.transport ?? "streamable-http");
}
setServerId(editingServerId);
setJsonText(serializeEntryToJson(editingServerId, initialEntry));
} else {
setJsonText("");
}
}, [isEdit, initialEntry, editingServerId]);
const parseArgsText = (
input: string,
): { ok: true; args: string[] } | { ok: false; error: string } => {
const raw = input.trim();
if (!raw) return { ok: true, args: [] };
if (raw.startsWith("[")) {
try {
const parsed = JSON.parse(raw);
if (
Array.isArray(parsed) &&
parsed.every((item) => typeof item === "string")
) {
return { ok: true, args: parsed };
}
return { ok: false, error: t("Claw.MCP.addServer.argsInvalid") };
} catch {
return { ok: false, error: t("Claw.MCP.addServer.argsInvalid") };
}
}
const tokens: string[] = [];
const tokenPattern =
/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = tokenPattern.exec(raw)) !== null) {
const value = match[1] ?? match[2] ?? match[3] ?? "";
tokens.push(value.replace(/\\(["'])/g, "$1"));
}
if (tokens.length === 0) {
return { ok: false, error: t("Claw.MCP.addServer.argsInvalid") };
}
return { ok: true, args: tokens };
};
const buildEntryFromForm = ():
| { ok: true; serverId: string; entry: McpServerEntry }
| { ok: false; error: string } => {
const id = serverId.trim();
if (!id) return { ok: false, error: t("Claw.MCP.addServer.idRequired") };
if (serverType === "stdio") {
const cmd = command.trim();
if (!cmd)
return { ok: false, error: t("Claw.MCP.addServer.commandRequired") };
const argsParsed = parseArgsText(argsText);
if (!argsParsed.ok) return argsParsed;
return {
ok: true,
serverId: id,
entry: {
command: cmd,
args: argsParsed.args,
enabled: initialEntry?.enabled ?? false,
},
};
}
const u = url.trim();
if (!u) return { ok: false, error: t("Claw.MCP.addServer.urlRequired") };
return {
ok: true,
serverId: id,
entry: { url: u, transport, enabled: initialEntry?.enabled ?? false },
};
};
const syncFormToJson = useCallback(() => {
const result = buildEntryFromForm();
if (result.ok) {
setJsonText(serializeEntryToJson(result.serverId, result.entry));
setJsonError("");
}
// buildEntryFromForm is stable; intentional exhaustive deps ok
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverType, serverId, command, argsText, url, transport]);
const handleTabChange = (val: string) => {
if (val === "json") {
syncFormToJson();
} else {
// JSON → Form: 尝试解析 JSON 并填充表单字段
if (!jsonText.trim()) {
setEditorTab("form");
return;
}
const parsed = parseServerFromJson(jsonText);
if (!parsed.ok) {
setJsonError(parsed.error);
return;
}
const { serverId: parsedId, entry } = parsed;
if ("command" in entry) {
setServerType("stdio");
setCommand(entry.command);
setArgsText(JSON.stringify(entry.args ?? []));
} else {
setServerType("remote");
setUrl(entry.url);
setTransport(entry.transport ?? "streamable-http");
}
if (!isEdit) {
setServerId(parsedId);
}
setJsonError("");
}
setEditorTab(val as "form" | "json");
};
const handleSave = () => {
const result = buildEntryFromForm();
if (!result.ok) {
message.error(result.error);
return;
}
if (!isEdit && existingServerIds.includes(result.serverId)) {
message.error(t("Claw.MCP.addServer.idDuplicate"));
return;
}
onSave(result.serverId, result.entry);
};
const handleTest = async () => {
const result = buildEntryFromForm();
if (!result.ok) {
message.error(result.error);
return;
}
if (!isEdit && existingServerIds.includes(result.serverId)) {
message.error(t("Claw.MCP.addServer.idDuplicate"));
return;
}
setTestLoading(true);
try {
// 优先用父组件内存中的配置(含其他未持久化的修改),回退到 DB 中的配置
const baseConfig = fullConfig ??
(await window.electronAPI?.mcp.getConfig()) ?? { mcpServers: {} };
const mergedServers = {
...(baseConfig.mcpServers ?? {}),
[result.serverId]: result.entry,
};
await window.electronAPI?.mcp.setConfig({
...baseConfig,
mcpServers: mergedServers,
});
const discoverResult = await window.electronAPI?.mcp.discoverTools(
result.serverId,
);
if (discoverResult?.success) {
const toolCount = discoverResult.tools?.length ?? 0;
message.success(t("Claw.MCP.list.testSuccess", { 0: toolCount }));
} else {
message.error(
t("Claw.MCP.list.testFailed", {
0: discoverResult?.error || "Unknown error",
}),
);
}
} catch (e) {
message.error(t("Claw.MCP.list.testFailed", { 0: String(e) }));
} finally {
setTestLoading(false);
}
};
const titleText = isEdit
? t("Claw.MCP.editor.editTitle")
: t("Claw.MCP.editor.createTitle");
return (
<Card
title={
<Space>
<Button icon={<ArrowLeftOutlined />} size="small" onClick={onBack}>
{t("Claw.MCP.editor.back")}
</Button>
<span>{titleText}</span>
</Space>
}
extra={
<Space>
<Button
icon={<CheckCircleOutlined />}
onClick={handleTest}
loading={testLoading}
size="small"
>
{t("Claw.MCP.list.test")}
</Button>
<Button
icon={<SaveOutlined />}
type="primary"
onClick={handleSave}
size="small"
>
{t("Claw.Common.save")}
</Button>
</Space>
}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Segmented
value={editorTab}
onChange={handleTabChange}
options={[
{ label: t("Claw.MCP.editor.tabForm"), value: "form" },
{ label: t("Claw.MCP.editor.tabJson"), value: "json" },
]}
/>
{editorTab === "form" ? (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<div>
<Text style={{ display: "block", marginBottom: 6 }}>
{t("Claw.MCP.addServer.type")}
</Text>
<Segmented
value={serverType}
options={[
{ label: "stdio", value: "stdio" },
{ label: "remote", value: "remote" },
]}
onChange={(val) => setServerType(val as "stdio" | "remote")}
/>
</div>
<div>
<Text style={{ display: "block", marginBottom: 6 }}>
{t("Claw.MCP.addServer.serverId")}
</Text>
<Input
value={serverId}
onChange={(e) => setServerId(e.target.value)}
placeholder={t("Claw.MCP.addServer.idPlaceholder")}
disabled={isEdit}
/>
</div>
{serverType === "stdio" ? (
<>
<div>
<Text style={{ display: "block", marginBottom: 6 }}>
Command
</Text>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={t("Claw.MCP.addServer.commandPlaceholder")}
/>
</div>
<div>
<Text style={{ display: "block", marginBottom: 6 }}>
Args
</Text>
<Input.TextArea
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
autoSize={{ minRows: 2, maxRows: 6 }}
placeholder={t(
"Claw.MCP.addServer.argsPlaceholderAdvanced",
)}
/>
</div>
</>
) : (
<>
<div>
<Text style={{ display: "block", marginBottom: 6 }}>URL</Text>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("Claw.MCP.addServer.urlPlaceholder")}
/>
</div>
<div>
<Text style={{ display: "block", marginBottom: 6 }}>
Transport
</Text>
<Select
value={transport}
onChange={(val) => setTransport(val)}
style={{ width: "100%" }}
options={[
{ label: "Streamable HTTP", value: "streamable-http" },
{ label: "SSE", value: "sse" },
]}
/>
</div>
</>
)}
</Space>
) : (
<div>
<Text
type="secondary"
style={{ marginBottom: 8, display: "block" }}
>
{t("Claw.MCP.editor.jsonHint")}
</Text>
<div
style={{
border: "1px solid #d9d9d9",
borderRadius: 4,
overflow: "hidden",
}}
>
<Editor
height="400px"
language="json"
theme={isDarkMode ? "vs-dark" : "vs"}
value={jsonText}
onChange={(value) => {
setJsonText(value || "");
if (jsonError) setJsonError("");
}}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
formatOnPaste: true,
formatOnType: true,
stickyScroll: { enabled: false },
}}
/>
</div>
{jsonError ? (
<Text type="danger" style={{ marginTop: 8, display: "block" }}>
{jsonError}
</Text>
) : null}
</div>
)}
</Space>
</Card>
);
}
export default MCPServerEditor;

View File

@@ -0,0 +1,805 @@
/**
* MCP Proxy 设置组件 - JSON 文本编辑器
*
* 使用稳定的文本编辑 + 解析校验,避免第三方可视化编辑器导致的不可编辑问题。
*/
import { useState, useEffect, useCallback } from "react";
import {
Card,
Button,
Space,
Badge,
Typography,
Segmented,
List,
Switch,
Tag,
Empty,
message,
Alert,
Spin,
Modal,
} from "antd";
import {
PlayCircleOutlined,
ReloadOutlined,
SaveOutlined,
ApiOutlined,
ExportOutlined,
ImportOutlined,
WarningOutlined,
CheckCircleOutlined,
EditOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import Editor from "@monaco-editor/react";
import type {
McpServersConfig,
McpProxyStatus,
McpServerEntry,
} from "@shared/types/electron";
import { t } from "../../services/core/i18n";
import MCPServerEditor from "./MCPServerEditor";
const { Text } = Typography;
interface MCPSettingsProps {
isOpen?: boolean;
onClose?: () => void;
}
function MCPSettings({ isOpen = true }: MCPSettingsProps) {
const [isDarkMode, setIsDarkMode] = useState(
document.body.getAttribute("data-theme") === "dark",
);
const [viewMode, setViewMode] = useState<"list" | "json">("list");
const [configText, setConfigText] = useState("{}");
const [configTextError, setConfigTextError] = useState<string>("");
const [status, setStatus] = useState<McpProxyStatus>({ running: false });
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [showExportWarning, setShowExportWarning] = useState(false);
const [pageMode, setPageMode] = useState<"list" | "editor">("list");
const [editorMode, setEditorMode] = useState<"create" | "edit">("create");
const [editingServerId, setEditingServerId] = useState("");
const [deletingServerId, setDeletingServerId] = useState<string | null>(null);
// 监听主题变化
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDarkMode(document.body.getAttribute("data-theme") === "dark");
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => observer.disconnect();
}, []);
const formatConfigForEditor = useCallback(
(value: McpServersConfig): string => {
return JSON.stringify(value, null, 2);
},
[],
);
const normalizeServerEntry = useCallback(
(entry: McpServerEntry, defaultEnabled: boolean): McpServerEntry => {
return {
...entry,
// 手动启用策略:缺省 enabled 时按 false 处理,必须手动打开才生效。
enabled: entry.enabled === undefined ? defaultEnabled : entry.enabled,
};
},
[],
);
const normalizeConfig = useCallback(
(config: McpServersConfig, defaultEnabled: boolean): McpServersConfig => {
const normalizedServers: Record<string, McpServerEntry> = {};
const sourceServers =
config && typeof config.mcpServers === "object" && config.mcpServers
? config.mcpServers
: {};
for (const [serverId, entry] of Object.entries(sourceServers)) {
if (!entry || typeof entry !== "object") continue;
normalizedServers[serverId] = normalizeServerEntry(
entry,
defaultEnabled,
);
}
return {
...config,
mcpServers: normalizedServers,
};
},
[normalizeServerEntry],
);
const applyConfigToEditor = useCallback(
(config: McpServersConfig, defaultEnabled: boolean) => {
const normalized = normalizeConfig(config, defaultEnabled);
setConfigText(formatConfigForEditor(normalized));
setConfigTextError("");
},
[formatConfigForEditor, normalizeConfig],
);
const parseConfigText = (
text: string,
): { ok: true; value: McpServersConfig } | { ok: false; error: string } => {
try {
const parsed = JSON.parse(text) as McpServersConfig;
if (!parsed || typeof parsed !== "object") {
return { ok: false, error: t("Claw.MCP.message.invalidJson") };
}
return { ok: true, value: parsed };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return { ok: false, error: reason };
}
};
// 将文本编辑区解析为配置对象并可选地写回格式化文本。
// 这样可以保证1) 编辑时有明确报错2) 保存前结构一定是有效 JSON。
const syncConfigFromText = (
formatText = false,
defaultEnabled = false,
setErrorState = true,
): McpServersConfig | null => {
const parsed = parseConfigText(configText);
if (!parsed.ok) {
if (setErrorState) {
setConfigTextError(parsed.error);
}
return null;
}
const normalized = normalizeConfig(parsed.value, defaultEnabled);
if (setErrorState) {
setConfigTextError("");
}
if (formatText) {
setConfigText(formatConfigForEditor(normalized));
}
return normalized;
};
const getCurrentConfigForUi = (): McpServersConfig | null => {
// UI 渲染阶段仅做无副作用解析,避免在 render 期间触发 setState。
return syncConfigFromText(false, false, false);
};
const updateConfigFromUi = (nextConfig: McpServersConfig) => {
applyConfigToEditor(nextConfig, false);
};
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [savedConfig, currentStatus] = await Promise.all([
window.electronAPI?.mcp.getConfig(),
window.electronAPI?.mcp.status(),
]);
if (savedConfig) {
// 加载时即按“手动启用”策略规范化,便于列表模式直观管理开关状态。
applyConfigToEditor(savedConfig, false);
}
if (currentStatus) setStatus(currentStatus);
} catch (error) {
console.error("[MCPSettings] Failed to load:", error);
} finally {
setLoading(false);
}
}, [applyConfigToEditor]);
useEffect(() => {
if (isOpen) {
loadAll();
}
}, [isOpen, loadAll]);
const refreshStatus = async () => {
try {
const currentStatus = await window.electronAPI?.mcp.status();
if (currentStatus) setStatus(currentStatus);
} catch {
// 状态刷新失败不打断主流程,保持当前 UI 状态。
}
};
const handleSaveConfig = async () => {
const nextConfig = syncConfigFromText(true, false);
if (!nextConfig) {
message.error(t("Claw.MCP.message.invalidJson"));
return;
}
try {
await window.electronAPI?.mcp.setConfig(nextConfig);
message.success(t("Claw.MCP.message.configSaved"));
} catch {
message.error(t("Claw.Common.saveFailed"));
}
};
const handleStart = async () => {
const nextConfig = syncConfigFromText(true, false);
if (!nextConfig) {
message.error(t("Claw.MCP.message.invalidJson"));
return;
}
setActionLoading(true);
try {
await window.electronAPI?.mcp.setConfig(nextConfig);
const result = await window.electronAPI?.mcp.start();
if (result?.success) {
message.success(t("Claw.MCP.message.proxyReady"));
} else {
message.error(t("Claw.MCP.message.checkFailed", { 0: result?.error }));
}
} catch (error) {
message.error(
t("Claw.MCP.message.error", {
0: error instanceof Error ? error.message : String(error),
}),
);
} finally {
await refreshStatus();
setActionLoading(false);
}
};
const handleRestart = async () => {
const nextConfig = syncConfigFromText(true, false);
if (!nextConfig) {
message.error(t("Claw.MCP.message.invalidJson"));
return;
}
setActionLoading(true);
try {
await window.electronAPI?.mcp.setConfig(nextConfig);
const result = await window.electronAPI?.mcp.restart();
if (result?.success) {
message.success(t("Claw.MCP.message.proxyReady"));
} else {
message.error(t("Claw.MCP.message.checkFailed", { 0: result?.error }));
}
} catch (error) {
message.error(
t("Claw.MCP.message.error", {
0: error instanceof Error ? error.message : String(error),
}),
);
} finally {
await refreshStatus();
setActionLoading(false);
}
};
const handleExportConfirm = async () => {
try {
const result = await window.electronAPI?.mcp.exportConfig();
if (result?.success) {
message.success(t("Claw.MCP.importExport.exportSuccess"));
}
} catch {
message.error(t("Claw.MCP.importExport.exportFailed"));
} finally {
setShowExportWarning(false);
}
};
const handleExport = () => {
setShowExportWarning(true);
};
const handleImport = async () => {
try {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const text = event.target?.result as string;
const imported = JSON.parse(text);
applyConfigToEditor(imported, false);
message.success(t("Claw.MCP.importExport.importSuccess"));
} catch {
message.error(t("Claw.MCP.importExport.importFailed"));
}
};
reader.readAsText(file);
};
input.click();
} catch {
message.error(t("Claw.MCP.importExport.importFailed"));
}
};
if (loading) {
return (
<div style={{ padding: 24, textAlign: "center" }}>
<Spin size="large" />
</div>
);
}
const currentConfig = getCurrentConfigForUi();
const currentServers = currentConfig?.mcpServers ?? {};
const serverEntries = Object.entries(currentServers);
const enabledCount = serverEntries.filter(
([, entry]) => !!entry.enabled,
).length;
const handleToggleServerEnabled = (serverId: string, enabled: boolean) => {
const latest = getCurrentConfigForUi();
if (!latest) {
message.error(t("Claw.MCP.message.invalidJson"));
return;
}
const target = latest.mcpServers[serverId];
if (!target) return;
const nextConfig: McpServersConfig = {
...latest,
mcpServers: {
...latest.mcpServers,
[serverId]: {
...target,
enabled,
},
},
};
updateConfigFromUi(nextConfig);
};
const handleDisableAllServers = () => {
const latest = getCurrentConfigForUi();
if (!latest) {
message.error(t("Claw.MCP.message.invalidJson"));
return;
}
const nextServers: Record<string, McpServerEntry> = {};
for (const [serverId, entry] of Object.entries(latest.mcpServers)) {
nextServers[serverId] = { ...entry, enabled: false };
}
updateConfigFromUi({ ...latest, mcpServers: nextServers });
message.success(t("Claw.MCP.list.disableAllSuccess"));
};
const handleDeleteServer = (serverId: string) => {
const latest = getCurrentConfigForUi();
if (!latest) {
message.error(t("Claw.MCP.message.invalidJson"));
return;
}
setDeletingServerId(serverId);
const nextServers = { ...latest.mcpServers };
delete nextServers[serverId];
updateConfigFromUi({ ...latest, mcpServers: nextServers });
message.success(t("Claw.MCP.message.serverRemoved"));
setDeletingServerId(null);
};
const handleTestServer = async (serverId: string) => {
try {
// 先确保内存中的最新配置已持久化到 DB再调用 discoverTools
const latest = getCurrentConfigForUi();
if (latest) {
await window.electronAPI?.mcp.setConfig(latest);
}
const result = await window.electronAPI?.mcp.discoverTools(serverId);
if (result?.success) {
const toolCount = result.tools?.length ?? 0;
message.success(t("Claw.MCP.list.testSuccess", { 0: toolCount }));
} else {
message.error(
t("Claw.MCP.list.testFailed", {
0: result?.error || "Unknown error",
}),
);
}
} catch (e) {
message.error(t("Claw.MCP.list.testFailed", { 0: String(e) }));
}
};
const handleOpenEditorCreate = () => {
setEditorMode("create");
setEditingServerId("");
setPageMode("editor");
};
const handleOpenEditorEdit = (serverId: string) => {
setEditorMode("edit");
setEditingServerId(serverId);
setPageMode("editor");
};
const handleEditorSave = (serverId: string, entry: McpServerEntry) => {
const latest = getCurrentConfigForUi();
if (!latest) {
message.error(t("Claw.MCP.message.invalidJson"));
return;
}
const nextConfig: McpServersConfig = {
...latest,
mcpServers: {
...latest.mcpServers,
[serverId]: entry,
},
};
updateConfigFromUi(nextConfig);
setPageMode("list");
message.success(
editorMode === "create"
? t("Claw.MCP.addServer.addSuccess")
: t("Claw.MCP.message.configSaved"),
);
};
const handleEditorBack = () => {
setPageMode("list");
};
if (pageMode === "editor") {
const editingEntry =
editorMode === "edit" && editingServerId
? currentServers[editingServerId]
: undefined;
return (
<div style={{ padding: 24 }}>
<MCPServerEditor
key={editorMode === "edit" ? editingServerId : "__create__"}
mode={editorMode}
editingServerId={editorMode === "edit" ? editingServerId : undefined}
initialEntry={editingEntry}
existingServerIds={Object.keys(currentServers)}
isDarkMode={isDarkMode}
fullConfig={currentConfig ?? undefined}
onSave={handleEditorSave}
onBack={handleEditorBack}
/>
</div>
);
}
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<ApiOutlined />
<span>{t("Claw.MCP.title")}</span>
<Badge
status={status.running ? "success" : "default"}
text={
status.running
? t("Claw.MCP.status.running")
: t("Claw.MCP.status.stopped")
}
/>
</Space>
}
extra={
<Space>
<Button
icon={<ImportOutlined />}
onClick={handleImport}
size="small"
>
{t("Claw.MCP.importExport.import")}
</Button>
<Button
icon={<ExportOutlined />}
onClick={handleExport}
size="small"
>
{t("Claw.MCP.importExport.export")}
</Button>
<Button
icon={<SaveOutlined />}
onClick={handleSaveConfig}
type="primary"
size="small"
>
{t("Claw.Common.save")}
</Button>
{status.running ? (
<Button
icon={<ReloadOutlined />}
onClick={handleRestart}
loading={actionLoading}
size="small"
>
{t("Claw.MCP.action.restart")}
</Button>
) : (
<Button
icon={<PlayCircleOutlined />}
onClick={handleStart}
loading={actionLoading}
size="small"
>
{t("Claw.MCP.action.start")}
</Button>
)}
</Space>
}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Alert
message={t("Claw.MCP.editor.title")}
description={t("Claw.MCP.editor.description")}
type="info"
showIcon
/>
<div>
<Space
style={{ width: "100%", justifyContent: "space-between" }}
wrap
>
<Segmented
value={viewMode}
onChange={(val) => setViewMode(val as "list" | "json")}
options={[
{ label: t("Claw.MCP.view.list"), value: "list" },
{ label: t("Claw.MCP.view.json"), value: "json" },
]}
/>
<Text type="secondary">
{t("Claw.MCP.list.enabledSummary", {
0: enabledCount,
1: serverEntries.length,
})}
</Text>
</Space>
</div>
{viewMode === "list" ? (
<div>
<Space
style={{
width: "100%",
marginBottom: 8,
justifyContent: "space-between",
}}
wrap
>
<Text strong style={{ display: "block" }}>
{t("Claw.MCP.list.title")}
</Text>
<Space>
<Button size="small" onClick={handleDisableAllServers}>
{t("Claw.MCP.list.disableAll")}
</Button>
<Button
size="small"
type="primary"
onClick={handleOpenEditorCreate}
>
{t("Claw.MCP.list.addServer")}
</Button>
</Space>
</Space>
<div
style={{
border: "1px solid #d9d9d9",
borderRadius: 8,
backgroundColor: "var(--color-bg-container, #fff)",
padding: 12,
}}
>
{serverEntries.length === 0 ? (
<div style={{ padding: 24 }}>
<Empty
description={t("Claw.MCP.serverManagement.noServers")}
/>
</div>
) : (
<List
dataSource={serverEntries}
renderItem={([serverId, entry]) => {
const isStdio = "command" in entry;
const summary = isStdio
? `${entry.command} ${(entry.args ?? []).join(" ")}`
: entry.url;
return (
<List.Item
actions={[
<Switch
key="enabled"
checked={!!entry.enabled}
checkedChildren={t("Claw.MCP.switch.enable")}
unCheckedChildren={t("Claw.MCP.switch.disable")}
onChange={(checked) =>
handleToggleServerEnabled(serverId, checked)
}
/>,
<>
<Button
key="test"
size="small"
type="text"
icon={<CheckCircleOutlined />}
onClick={() => handleTestServer(serverId)}
/>
<Button
key="edit"
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditorEdit(serverId)}
/>
<Button
key="delete"
size="small"
danger
type="text"
loading={deletingServerId === serverId}
icon={<DeleteOutlined />}
onClick={() => handleDeleteServer(serverId)}
/>
</>,
]}
>
<List.Item.Meta
title={
<Space>
<Text strong>{serverId}</Text>
<Tag color={isStdio ? "blue" : "purple"}>
{isStdio ? "stdio" : "remote"}
</Tag>
</Space>
}
description={
<Text
type="secondary"
style={{
display: "inline-block",
maxWidth: 680,
}}
ellipsis={{ tooltip: summary }}
>
{summary}
</Text>
}
/>
</List.Item>
);
}}
/>
)}
</div>
</div>
) : (
<div>
<Text strong style={{ marginBottom: 8, display: "block" }}>
{t("Claw.MCP.editor.config")}
</Text>
<div
style={{
border: "1px solid #d9d9d9",
borderRadius: 8,
overflow: "hidden",
position: "relative",
}}
>
<Editor
height="400px"
language="json"
theme={isDarkMode ? "vs-dark" : "vs"}
value={configText}
onChange={(value) => {
setConfigText(value || "");
if (configTextError) {
setConfigTextError("");
}
}}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
formatOnPaste: true,
formatOnType: true,
stickyScroll: { enabled: false },
}}
/>
</div>
{configTextError ? (
<Text type="danger" style={{ marginTop: 8, display: "block" }}>
{configTextError}
</Text>
) : null}
<div style={{ marginTop: 8 }}>
<Button
size="small"
onClick={() => {
const parsed = syncConfigFromText(true, false);
if (!parsed) {
message.error(t("Claw.MCP.message.invalidJson"));
}
}}
>
{t("Claw.MCP.editor.format")}
</Button>
</div>
</div>
)}
<Alert
message={t("Claw.MCP.editor.exampleTitle")}
description={
<pre
style={{
margin: 0,
fontFamily: "Monaco, Menlo, 'Courier New', monospace",
fontSize: 12,
whiteSpace: "pre-wrap",
color: "var(--color-text)",
}}
>
{`{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"],
"enabled": true
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "your_token_here"
},
"enabled": true
},
"postgres": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
"enabled": false
}
}
}`}
</pre>
}
type="success"
/>
</Space>
</Card>
{/* 导出警告 Modal */}
<Modal
title={
<Space>
<WarningOutlined style={{ color: "#faad14" }} />
{t("Claw.MCP.importExport.exportWarningTitle")}
</Space>
}
open={showExportWarning}
onOk={handleExportConfirm}
onCancel={() => setShowExportWarning(false)}
okText={t("Claw.Common.confirm")}
cancelText={t("Claw.Common.cancel")}
>
<Alert
message={t("Claw.MCP.importExport.exportWarningContent")}
type="warning"
showIcon
/>
</Modal>
</div>
);
}
export default MCPSettings;

View File

@@ -0,0 +1,207 @@
import { useState, useEffect, useRef } from "react";
import { t } from "../../services/core/i18n";
import { fileServerService } from "../../services/integrations/fileServer";
import { LOCAL_HOST_URL, DEFAULT_FILE_SERVER_PORT } from "@shared/constants";
interface SkillsSyncProps {
isOpen: boolean;
onClose: () => void;
}
function SkillsSync({ isOpen, onClose }: SkillsSyncProps) {
const [baseUrl, setBaseUrl] = useState(
`${LOCAL_HOST_URL}:${DEFAULT_FILE_SERVER_PORT}`,
);
const [connected, setConnected] = useState(false);
const [checking, setChecking] = useState(false);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState("");
const [logs, setLogs] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
loadConfig();
}
}, [isOpen]);
const loadConfig = async () => {
await fileServerService.loadConfig();
const config = fileServerService.getConfig();
setBaseUrl(config.baseUrl);
checkConnection();
};
const saveConfig = async () => {
fileServerService.setConfig({ baseUrl });
await fileServerService.saveConfig();
setMessage(t("Claw.SkillsSync.configSaved"));
setTimeout(() => setMessage(""), 2000);
};
const checkConnection = async () => {
setChecking(true);
const isConnected = await fileServerService.checkConnection();
setConnected(isConnected);
setChecking(false);
};
const handleUpload = async () => {
const file = fileInputRef.current?.files?.[0];
if (!file) {
setMessage(t("Claw.SkillsSync.selectZipFile"));
return;
}
if (!file.name.endsWith(".zip")) {
setMessage(t("Claw.SkillsSync.onlyZipSupported"));
return;
}
setUploading(true);
setMessage("");
setLogs([]);
try {
// Generate unique IDs
const userId = "local-user";
const cId = crypto.randomUUID();
addLog(t("Claw.SkillsSync.creatingWorkspaceAndSyncing"));
addLog(`User ID: ${userId}`);
addLog(`Session ID: ${cId}`);
addLog(`File: ${file.name}`);
const result = await fileServerService.createWorkspace(userId, cId, file);
addLog(`${t("Claw.SkillsSync.workspaceCreatedSuccess")}`);
addLog(`Message: ${result.message}`);
setMessage(t("Claw.SkillsSync.syncSuccess"));
} catch (error) {
addLog(`${t("Claw.SkillsSync.errorPrefix", { 0: String(error) })}`);
setMessage(t("Claw.SkillsSync.errorPrefix", { 0: String(error) }));
} finally {
setUploading(false);
}
};
const addLog = (text: string) => {
const timestamp = new Date().toLocaleTimeString();
setLogs((prev) => [...prev, `[${timestamp}] ${text}`]);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setMessage(
t("Claw.SkillsSync.fileSelected", {
0: file.name,
1: (file.size / 1024).toFixed(1),
}),
);
}
};
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content skills-sync-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>{t("Claw.SkillsSync.title")}</h2>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
<div className="skills-sync-section">
<h3>{t("Claw.SkillsSync.fileServiceConnection")}</h3>
<div className="form-group">
<label>{t("Claw.SkillsSync.serviceAddress")}</label>
<div className="input-with-button">
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={`${LOCAL_HOST_URL}:${DEFAULT_FILE_SERVER_PORT}`}
/>
<button
className={`check-btn ${checking ? "checking" : ""}`}
onClick={checkConnection}
disabled={checking}
>
{checking
? "..."
: connected
? `${t("Claw.SkillsSync.connected")}`
: `${t("Claw.SkillsSync.testConnection")}`}
</button>
</div>
</div>
<button className="save-btn small" onClick={saveConfig}>
{t("Claw.Common.save")}
</button>
</div>
<div className="skills-sync-section">
<h3>{t("Claw.SkillsSync.uploadSkillPackage")}</h3>
<p
className="hint"
dangerouslySetInnerHTML={{
__html: t("Claw.SkillsSync.uploadHint"),
}}
/>
<div className="upload-area">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleFileChange}
disabled={uploading}
/>
<button
className="upload-btn"
onClick={handleUpload}
disabled={uploading || !connected}
>
{uploading
? t("Claw.SkillsSync.uploading")
: t("Claw.SkillsSync.syncSkills")}
</button>
</div>
</div>
{logs.length > 0 && (
<div className="skills-sync-section">
<h3>{t("Claw.SkillsSync.logs")}</h3>
<div className="log-area">
{logs.map((log, i) => (
<div key={i} className="log-line">
{log}
</div>
))}
</div>
</div>
)}
{message && (
<div
className={`skills-sync-message ${message.includes(t("Claw.SkillsSync.errorPrefix").split("{0}")[0]) ? "error" : "success"}`}
>
{message}
</div>
)}
</div>
</div>
);
}
export default SkillsSync;

View File

@@ -0,0 +1,421 @@
import { useState, useEffect } from "react";
import { t } from "../../services/core/i18n";
import {
taskScheduler,
ScheduledTask,
TaskSchedule,
TaskAction,
} from "../../services/integrations/scheduler";
interface TaskSettingsProps {
isOpen: boolean;
onClose: () => void;
}
function TaskSettings({ isOpen, onClose }: TaskSettingsProps) {
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [editingTask, setEditingTask] = useState<ScheduledTask | null>(null);
const [message, setMessage] = useState("");
// New task form state
const [newTask, setNewTask] = useState({
name: "",
description: "",
scheduleType: "interval" as "once" | "interval" | "cron",
intervalValue: 60,
intervalUnit: "minutes",
actionType: "message" as "message" | "command" | "webhook",
actionContent: "",
webhookUrl: "",
});
useEffect(() => {
if (isOpen) {
loadTasks();
}
}, [isOpen]);
const loadTasks = async () => {
await taskScheduler.loadTasks();
setTasks(taskScheduler.getTasks());
};
const handleSave = async () => {
await taskScheduler.saveTasks();
setMessage(t("Claw.TaskSettings.taskSaved"));
setTimeout(() => setMessage(""), 2000);
};
const handleAddTask = async () => {
if (!newTask.name || !newTask.actionContent) {
setMessage(t("Claw.TaskSettings.fillRequiredFields"));
return;
}
let intervalMs = 60000;
switch (newTask.intervalUnit) {
case "minutes":
intervalMs = newTask.intervalValue * 60000;
break;
case "hours":
intervalMs = newTask.intervalValue * 3600000;
break;
case "days":
intervalMs = newTask.intervalValue * 86400000;
break;
}
const schedule: TaskSchedule = {
type: newTask.scheduleType,
...(newTask.scheduleType === "interval" && { intervalMs }),
};
const action: TaskAction = {
type: newTask.actionType,
content: newTask.actionContent,
...(newTask.actionType === "webhook" && {
url: newTask.webhookUrl,
method: "POST",
}),
};
taskScheduler.createTask({
name: newTask.name,
description: newTask.description,
enabled: true,
schedule,
action,
});
setShowAddForm(false);
setNewTask({
name: "",
description: "",
scheduleType: "interval",
intervalValue: 60,
intervalUnit: "minutes",
actionType: "message",
actionContent: "",
webhookUrl: "",
});
setTasks(taskScheduler.getTasks());
setMessage(t("Claw.TaskSettings.taskCreated"));
setTimeout(() => setMessage(""), 2000);
};
const handleToggleTask = (id: string, enabled: boolean) => {
taskScheduler.toggleTask(id, enabled);
setTasks(taskScheduler.getTasks());
};
const handleDeleteTask = (id: string) => {
taskScheduler.deleteTask(id);
setTasks(taskScheduler.getTasks());
setMessage(t("Claw.TaskSettings.taskDeleted"));
setTimeout(() => setMessage(""), 2000);
};
const handleRunNow = async (id: string) => {
setMessage(t("Claw.TaskSettings.executingTask"));
const result = await taskScheduler.runTask(id);
setMessage(
result.success
? t("Claw.TaskSettings.taskCompleted")
: t("Claw.TaskSettings.taskError", result.error || ""),
);
setTimeout(() => setMessage(""), 2000);
setTasks(taskScheduler.getTasks());
};
const formatNextRun = (timestamp?: number) => {
if (!timestamp) return t("Claw.TaskSettings.none");
const diff = timestamp - Date.now();
if (diff < 0) return t("Claw.TaskSettings.immediate");
if (diff < 60000)
return t("Claw.TaskSettings.seconds", Math.round(diff / 1000).toString());
if (diff < 3600000)
return t(
"Claw.TaskSettings.minutes",
Math.round(diff / 60000).toString(),
);
return t("Claw.TaskSettings.hours", Math.round(diff / 3600000).toString());
};
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content task-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>{t("Claw.TaskSettings.title")}</h2>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
<div className="task-content">
<div className="task-header">
<span className="task-count">
{t("Claw.TaskSettings.taskCount", tasks.length.toString())}
</span>
<button
className="add-task-btn"
onClick={() => setShowAddForm(!showAddForm)}
>
{showAddForm
? t("Claw.TaskSettings.cancel")
: t("Claw.TaskSettings.addTask")}
</button>
</div>
{showAddForm && (
<div className="task-form">
<div className="form-group">
<label>{t("Claw.TaskSettings.taskNameRequired")}</label>
<input
type="text"
value={newTask.name}
onChange={(e) =>
setNewTask({ ...newTask, name: e.target.value })
}
placeholder={t("Claw.TaskSettings.taskNamePlaceholder")}
/>
</div>
<div className="form-group">
<label>{t("Claw.TaskSettings.description")}</label>
<input
type="text"
value={newTask.description}
onChange={(e) =>
setNewTask({ ...newTask, description: e.target.value })
}
placeholder={t("Claw.TaskSettings.descriptionPlaceholder")}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>{t("Claw.TaskSettings.scheduleType")}</label>
<select
value={newTask.scheduleType}
onChange={(e) =>
setNewTask({
...newTask,
scheduleType: e.target.value as any,
})
}
>
<option value="interval">
{t("Claw.TaskSettings.interval")}
</option>
<option value="once">{t("Claw.TaskSettings.once")}</option>
<option value="cron">{t("Claw.TaskSettings.cron")}</option>
</select>
</div>
{newTask.scheduleType === "interval" && (
<div className="form-group">
<label>{t("Claw.TaskSettings.interval")}</label>
<div className="interval-input">
<input
type="number"
value={newTask.intervalValue}
onChange={(e) =>
setNewTask({
...newTask,
intervalValue: parseInt(e.target.value),
})
}
min={1}
/>
<select
value={newTask.intervalUnit}
onChange={(e) =>
setNewTask({
...newTask,
intervalUnit: e.target.value as any,
})
}
>
<option value="minutes">
{t("Claw.TaskSettings.minutesOption")}
</option>
<option value="hours">
{t("Claw.TaskSettings.hoursOption")}
</option>
<option value="days">
{t("Claw.TaskSettings.daysOption")}
</option>
</select>
</div>
</div>
)}
</div>
<div className="form-group">
<label>{t("Claw.TaskSettings.actionType")}</label>
<select
value={newTask.actionType}
onChange={(e) =>
setNewTask({
...newTask,
actionType: e.target.value as any,
})
}
>
<option value="message">
{t("Claw.TaskSettings.sendMessage")}
</option>
<option value="command">
{t("Claw.TaskSettings.executeCommand")}
</option>
<option value="webhook">
{t("Claw.TaskSettings.webhook")}
</option>
</select>
</div>
<div className="form-group">
<label>{t("Claw.TaskSettings.contentRequired")}</label>
{newTask.actionType === "message" && (
<textarea
value={newTask.actionContent}
onChange={(e) =>
setNewTask({ ...newTask, actionContent: e.target.value })
}
placeholder={t("Claw.TaskSettings.contentPlaceholder")}
rows={3}
/>
)}
{newTask.actionType === "command" && (
<input
type="text"
value={newTask.actionContent}
onChange={(e) =>
setNewTask({ ...newTask, actionContent: e.target.value })
}
placeholder={t("Claw.TaskSettings.commandPlaceholder")}
/>
)}
{newTask.actionType === "webhook" && (
<>
<input
type="text"
value={newTask.webhookUrl}
onChange={(e) =>
setNewTask({ ...newTask, webhookUrl: e.target.value })
}
placeholder={t("Claw.TaskSettings.webhookUrlPlaceholder")}
/>
<input
type="text"
value={newTask.actionContent}
onChange={(e) =>
setNewTask({
...newTask,
actionContent: e.target.value,
})
}
placeholder={t(
"Claw.TaskSettings.requestBodyPlaceholder",
)}
style={{ marginTop: "8px" }}
/>
</>
)}
</div>
<button className="create-task-btn" onClick={handleAddTask}>
{t("Claw.TaskSettings.createTask")}
</button>
</div>
)}
<div className="task-list">
{tasks.length === 0 && !showAddForm && (
<div className="empty-state">
<p>{t("Claw.TaskSettings.noTasks")}</p>
<p className="hint">{t("Claw.TaskSettings.noTasksHint")}</p>
</div>
)}
{tasks.map((task) => (
<div key={task.id} className={`task-item ${task.status}`}>
<div className="task-info">
<div className="task-header-row">
<span className="task-name">{task.name}</span>
<span className={`task-status ${task.status}`}>
{task.status === "running" && "⟳"}
{task.status === "success" && "✓"}
{task.status === "error" && "✗"}
{task.status === "idle" && "○"}
</span>
</div>
{task.description && (
<div className="task-desc">{task.description}</div>
)}
<div className="task-meta">
<span>
{t(
"Claw.TaskSettings.nextRun",
formatNextRun(task.nextRun),
)}
</span>
{task.lastRun && (
<span>
{t(
"Claw.TaskSettings.lastRun",
formatNextRun(Date.now() - task.lastRun),
)}
</span>
)}
</div>
</div>
<div className="task-actions">
<label className="toggle">
<input
type="checkbox"
checked={task.enabled}
onChange={(e) =>
handleToggleTask(task.id, e.target.checked)
}
/>
<span className="toggle-slider"></span>
</label>
<button
className="run-btn"
onClick={() => handleRunNow(task.id)}
>
</button>
<button
className="delete-btn"
onClick={() => handleDeleteTask(task.id)}
>
×
</button>
</div>
</div>
))}
</div>
</div>
{message && <div className="task-message">{message}</div>}
<div className="modal-footer">
<button className="save-btn" onClick={handleSave}>
{t("Claw.TaskSettings.saveTask")}
</button>
</div>
</div>
</div>
);
}
export default TaskSettings;

View File

@@ -0,0 +1,704 @@
/**
* 初始化向导 - 依赖检测/安装 (Electron 版)
*
* 流程:
* 1. checking → 检测所有依赖
* 2. 若 uv 不满足 → system-deps-missing → 提示手动安装 + 刷新
* 3. 若 npm 包缺失 → installing → 自动安装 + 进度
* 4. 所有依赖就绪 → completed → 自动进入下一步
*
* 与 Tauri 版的差异:
* - 移除 Node.js 自动安装逻辑Electron 内嵌 Node.js
* - invoke() → window.electronAPI.dependencies.*
* - openUrl() → window.electronAPI.shell.openExternal()
*/
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button, Progress, Alert, Spin } from "antd";
import {
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
ReloadOutlined,
LinkOutlined,
} from "@ant-design/icons";
import type { LocalDependencyItem } from "@shared/types/electron";
import { I18N_KEYS } from "@shared/constants";
import { t } from "../../services/core/i18n";
export type InstallPhase =
| "checking"
| "system-deps-missing"
| "installing"
| "completed"
| "error";
export interface DisplayDependencyItem {
name: string;
displayName: string;
type: "system" | "npm-local" | "npm-global" | "shell-installer" | "bundled";
description: string;
status:
| "checking"
| "installed"
| "missing"
| "outdated"
| "installing"
| "bundled"
| "error";
version?: string;
requiredVersion?: string;
errorMessage?: string;
installUrl?: string;
/** 初始化安装/升级时使用的固定版本 */
installVersion?: string;
}
/** Mock API 接口(用于测试) */
export interface MockDependenciesApi {
checkAll: () => Promise<{
success: boolean;
results?: LocalDependencyItem[];
error?: string;
}>;
checkUv: () => Promise<{
success: boolean;
installed: boolean;
bundled?: boolean;
version?: string;
}>;
installPackage: (
name: string,
options?: { version?: string },
) => Promise<{ success: boolean; version?: string; error?: string }>;
openExternal: (url: string) => Promise<unknown>;
}
interface SetupDependenciesProps {
onComplete: () => void;
/** 可选:注入 Mock API 用于测试 */
mockApi?: MockDependenciesApi;
}
export default function SetupDependencies({
onComplete,
mockApi,
}: SetupDependenciesProps) {
const [allDependencies, setAllDependencies] = useState<
DisplayDependencyItem[]
>([]);
const [installPhase, setInstallPhase] = useState<InstallPhase>("checking");
const [installProgress, setInstallProgress] = useState(0);
const [currentInstalling, setCurrentInstalling] = useState<string>("");
const [installError, setInstallError] = useState<string>("");
const [showAll, setShowAll] = useState(true);
/** 初始化安装检查时显式确认 uv 的结果,用于在界面展示「浏览器确认有 uv」 */
const [uvConfirm, setUvConfirm] = useState<{
installed: boolean;
bundled?: boolean;
version?: string;
} | null>(null);
const projectInstallTriggered = useRef(false);
// 使用 ref 存储 onComplete避免 useCallback 依赖
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
// API 访问器(支持 mock 注入,使用 useMemo 避免每次渲染重建)
const api = useMemo(
() =>
mockApi || {
checkAll: () =>
window.electronAPI?.dependencies.checkAll() as Promise<{
success: boolean;
results?: LocalDependencyItem[];
error?: string;
}>,
checkUv: () =>
window.electronAPI?.dependencies.checkUv() as Promise<{
success: boolean;
installed: boolean;
bundled?: boolean;
version?: string;
}>,
installPackage: (name: string, options?: { version?: string }) =>
window.electronAPI?.dependencies.installPackage(
name,
options,
) as Promise<{ success: boolean; version?: string; error?: string }>,
openExternal: (url: string) =>
window.electronAPI?.shell.openExternal(url) as Promise<unknown>,
},
[mockApi],
);
const openUrl = useCallback(
async (url: string) => {
try {
await api.openExternal(url);
} catch (e) {
console.error("[SetupDeps] openExternal failed:", e);
}
},
[api],
);
const checkAllDeps = useCallback(async () => {
setInstallPhase("checking");
try {
const result = await api.checkAll();
if (!result?.success || !result.results) {
throw new Error(result?.error || t("Claw.Dependencies.checkFailed"));
}
const deps: LocalDependencyItem[] = result.results;
const unified: DisplayDependencyItem[] = deps.map((d) => ({
name: d.name,
displayName: d.displayName,
type: d.type,
description: d.description,
status: d.status,
version: d.version,
requiredVersion: d.minVersion ? `>= ${d.minVersion}` : undefined,
errorMessage: d.errorMessage,
installUrl:
d.type === "system" && d.name === "uv"
? "https://docs.astral.sh/uv/getting-started/installation/"
: undefined,
installVersion: d.installVersion,
}));
setAllDependencies(unified);
// 初始化安装检查:显式再调一次 checkUv便于在界面展示「已确认 uv」
try {
const uvRes = await api.checkUv();
if (uvRes?.success && uvRes.installed) {
setUvConfirm({
installed: true,
bundled: uvRes.bundled,
version: uvRes.version,
});
} else {
setUvConfirm({ installed: false });
}
} catch {
setUvConfirm({ installed: false });
}
// 检查系统依赖
const systemMissing = unified.some(
(d) =>
d.type === "system" &&
d.status !== "installed" &&
d.status !== "bundled",
);
if (systemMissing) {
setInstallPhase("system-deps-missing");
return;
}
// 检查是否全部就绪
if (
unified.every((d) => d.status === "installed" || d.status === "bundled")
) {
setInstallPhase("completed");
setTimeout(() => onCompleteRef.current(), 1500);
} else {
// 有 npm 包需要安装
setInstallPhase("installing");
}
} catch (error) {
console.error("[SetupDeps] Check failed:", error);
setInstallPhase("error");
setInstallError(
error instanceof Error
? error.message
: t("Claw.Dependencies.checkFailed"),
);
}
}, [api]);
// 组件挂载后立即检测依赖
useEffect(() => {
checkAllDeps();
}, [checkAllDeps]);
// 安装逻辑(移入 useEffect 避免闭包问题)
useEffect(() => {
if (installPhase !== "installing" || projectInstallTriggered.current)
return;
projectInstallTriggered.current = true;
setInstallProgress(0);
setInstallError("");
const runInstall = async () => {
// 使用函数式更新获取最新状态
let currentDeps: DisplayDependencyItem[] = [];
setAllDependencies((prev) => {
currentDeps = prev;
return prev;
});
const toInstall = currentDeps.filter(
(d) =>
(d.type === "npm-local" ||
d.type === "npm-global" ||
d.type === "shell-installer") &&
d.status !== "installed" &&
d.status !== "bundled",
);
const total = toInstall.length;
if (total === 0) {
setInstallProgress(100);
setInstallPhase("completed");
setTimeout(() => onCompleteRef.current(), 1500);
return;
}
for (let i = 0; i < toInstall.length; i++) {
const pkg = toInstall[i];
setCurrentInstalling(pkg.displayName);
setInstallProgress(Math.round((i / total) * 100));
setAllDependencies((prev) =>
prev.map((d) =>
d.name === pkg.name ? { ...d, status: "installing" as const } : d,
),
);
try {
const result = await api.installPackage(
pkg.name,
pkg.installVersion ? { version: pkg.installVersion } : undefined,
);
if (result?.success) {
setAllDependencies((prev) =>
prev.map((d) =>
d.name === pkg.name
? {
...d,
status: "installed" as const,
version: result.version,
}
: d,
),
);
} else {
setAllDependencies((prev) =>
prev.map((d) =>
d.name === pkg.name
? {
...d,
status: "error" as const,
errorMessage: result?.error,
}
: d,
),
);
setInstallPhase("error");
setInstallError(
result?.error ||
t("Claw.Dependencies.installPkgFailed", {
pkg: pkg.displayName,
}),
);
return;
}
} catch (error) {
setAllDependencies((prev) =>
prev.map((d) =>
d.name === pkg.name
? {
...d,
status: "error" as const,
errorMessage:
error instanceof Error
? error.message
: t("Claw.Dependencies.installFailed"),
}
: d,
),
);
setInstallPhase("error");
setInstallError(
error instanceof Error
? error.message
: t("Claw.Dependencies.installFailed"),
);
return;
}
}
setInstallProgress(100);
setInstallPhase("completed");
setTimeout(() => onCompleteRef.current(), 1500);
};
runInstall();
}, [installPhase, api]);
const getStatusIcon = (item: DisplayDependencyItem) => {
switch (item.status) {
case "installed":
case "bundled":
return (
<CheckCircleOutlined
style={{ color: "var(--color-success)", fontSize: 12 }}
/>
);
case "missing":
case "outdated":
return (
<ExclamationCircleOutlined
style={{ color: "var(--color-warning)", fontSize: 12 }}
/>
);
case "installing":
return (
<LoadingOutlined
style={{ color: "var(--color-text-tertiary)", fontSize: 12 }}
/>
);
case "error":
return (
<CloseCircleOutlined
style={{ color: "var(--color-error)", fontSize: 12 }}
/>
);
default:
return <LoadingOutlined style={{ fontSize: 12 }} />;
}
};
const stats = {
total: allDependencies.length,
ready: allDependencies.filter(
(d) => d.status === "installed" || d.status === "bundled",
).length,
};
const displayDeps = showAll
? allDependencies
: allDependencies.filter(
(d) => d.status !== "installed" && d.status !== "bundled",
);
const renderDependencyList = () => (
<div style={{ marginBottom: 12 }}>
{/* 初始化安装检查:浏览器确认 uv 已就绪时展示 */}
{uvConfirm?.installed && (
<Alert
type="success"
showIcon
message={
<span>
{t("Claw.Dependencies.uvConfirmed", {
context: uvConfirm.bundled ? "bundled" : "system",
version: uvConfirm.version || "",
})}
</span>
}
style={{ marginBottom: 12 }}
/>
)}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 500 }}>
{t("Claw.Dependencies.dependencyList")}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-tertiary)" }}>
{stats.ready}/{stats.total}
</span>
</div>
<Button
type="link"
size="small"
onClick={() => setShowAll((p) => !p)}
style={{ padding: 0, height: "auto", fontSize: 11 }}
>
{showAll
? t("Claw.Dependencies.showOnlyProblems")
: t("Claw.Dependencies.showAll")}
</Button>
</div>
<div
style={{
border: "1px solid var(--color-border)",
borderRadius: 6,
overflow: "hidden",
background: "var(--color-bg-container)",
}}
>
{displayDeps.length === 0 ? (
<div
style={{
padding: 12,
textAlign: "center",
fontSize: 12,
color: "var(--color-text-tertiary)",
}}
>
{t("Claw.Dependencies.noProblemItems")}
</div>
) : (
displayDeps.map((item, i) => {
const isProblem =
item.status !== "installed" && item.status !== "bundled";
const isSystem = item.type === "system";
const needsAction =
item.status === "missing" || item.status === "outdated";
// 状态对应的背景色
const getBgColor = () => {
if (item.status === "installed" || item.status === "bundled") {
return "var(--color-success-bg)";
}
if (item.status === "error") {
return "var(--color-error-bg)";
}
return "var(--color-warning-bg)";
};
return (
<div
key={item.name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px",
borderBottom:
i < displayDeps.length - 1
? "1px solid var(--color-border-secondary)"
: "none",
background: getBgColor(),
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
{getStatusIcon(item)}
<span style={{ fontSize: 12, fontWeight: 500 }}>
{item.displayName}
</span>
{item.version && (
<span
style={{
fontSize: 11,
color: "var(--color-text-tertiary)",
}}
>
v{item.version}
</span>
)}
</div>
{isProblem && (
<div
style={{
fontSize: 11,
color: "var(--color-text-secondary)",
marginTop: 2,
marginLeft: 18,
}}
>
{item.description}
{item.requiredVersion && (
<span> ({item.requiredVersion})</span>
)}
{item.errorMessage && (
<span
style={{ color: "var(--color-error)", marginLeft: 4 }}
>
{item.errorMessage}
</span>
)}
</div>
)}
</div>
{isSystem && needsAction && item.installUrl && (
<Button
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => openUrl(item.installUrl!)}
style={{ padding: 0, fontSize: 12 }}
>
{t("Claw.Dependencies.install")}
</Button>
)}
</div>
);
})
)}
</div>
</div>
);
// ========== 渲染 ==========
const isErrorPhase =
installPhase === "system-deps-missing" || installPhase === "error";
// 正常自动流程:只显示 loading + 状态文字
if (!isErrorPhase) {
const hasOutdated = allDependencies.some((d) => d.status === "outdated");
const installVerb = hasOutdated
? t("Claw.Dependencies.installAndUpgrade")
: t("Claw.Dependencies.install");
const phaseText: Record<string, string> = {
checking: t("Claw.Dependencies.checkingEnv"),
installing: currentInstalling
? t("Claw.Dependencies.installingDep", {
pkg: currentInstalling,
verb: installVerb,
})
: t("Claw.Dependencies.installingAllDep", { verb: installVerb }),
completed: t(I18N_KEYS.Components.Action.ALL_READY),
};
return (
<div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "40px 16px",
}}
>
{installPhase === "completed" ? (
<CheckCircleOutlined
style={{ fontSize: 40, color: "var(--color-success)" }}
/>
) : (
<Spin size="large" />
)}
<div style={{ fontSize: 14, fontWeight: 500 }}>
{phaseText[installPhase] || t(I18N_KEYS.Components.Action.STARTING)}
</div>
{installPhase === "installing" && (
<div style={{ width: "100%", maxWidth: 300 }}>
<Progress
size="small"
percent={installProgress}
status="active"
/>
</div>
)}
{installPhase === "completed" && (
<div style={{ fontSize: 12, color: "var(--color-text-tertiary)" }}>
{t("Claw.Dependencies.enteringNextStep")}
</div>
)}
</div>
</div>
);
}
// ========== 错误/需要用户介入的阶段 ==========
// 通用错误npm 安装失败等)
if (installPhase === "error") {
const hasOutdated = allDependencies.some((d) => d.status === "outdated");
const installLabel = hasOutdated
? t("Claw.Dependencies.installAndUpgrade")
: t("Claw.Dependencies.install");
const retryLabel = hasOutdated
? t("Claw.Dependencies.retryInstallAndUpgrade")
: t("Claw.Dependencies.retryInstall");
return (
<div>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 16 }}>
{installLabel}
</div>
{renderDependencyList()}
<Alert
message={
hasOutdated
? t("Claw.Dependencies.upgradeFailed")
: t("Claw.Dependencies.installFailed")
}
description={installError}
type="error"
showIcon
style={{ marginBottom: 12 }}
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<Button
onClick={() => {
projectInstallTriggered.current = false;
setInstallPhase("checking");
}}
>
{t("Claw.Dependencies.recheck")}
</Button>
<Button
type="primary"
onClick={() => {
projectInstallTriggered.current = false;
setInstallPhase("installing");
}}
>
{retryLabel}
</Button>
</div>
</div>
);
}
// system-deps-missing系统依赖缺失如 uv
return (
<div>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 16 }}>
{t("Claw.Dependencies.install")}
</div>
<Alert
message={t("Claw.Dependencies.pleaseInstallSystemDeps")}
type="warning"
showIcon
style={{ marginBottom: 12 }}
/>
{renderDependencyList()}
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
paddingTop: 8,
borderTop: "1px solid var(--color-border-secondary)",
}}
>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => {
projectInstallTriggered.current = false;
setInstallPhase("checking");
}}
>
{t("Claw.Dependencies.recheck")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,794 @@
/**
* 初始化向导组件 - 4 阶段流程
*
* 阶段 1: 依赖检测/安装dependenciesReady === false
* 阶段 2: 基础设置currentStep === 1
* 阶段 3: 账号登录currentStep === 2
* 阶段 4: 完成Result + 启动服务)
*/
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import { t } from "../../services/core/i18n";
import { useI18nLang } from "../../App";
import {
Steps,
Form,
Input,
InputNumber,
Button,
Space,
Result,
Spin,
message,
Alert,
Typography,
} from "antd";
import {
SettingOutlined,
UserOutlined,
CheckCircleOutlined,
RobotOutlined,
FolderOpenOutlined,
LockOutlined,
GlobalOutlined,
} from "@ant-design/icons";
import {
setupService,
Step1Config,
DEFAULT_STEP1_CONFIG,
} from "../../services/core/setup";
import {
loginAndRegister,
normalizeServerHost,
isLoggedIn as checkIsLoggedIn,
getCurrentAuth,
getAuthErrorMessage,
logout,
} from "../../services/core/auth";
import { AUTH_KEYS } from "@shared/constants";
import type { QuickInitConfig } from "@shared/types/quickInit";
import SetupDependencies, {
type MockDependenciesApi,
} from "./SetupDependencies";
const { Text } = Typography;
import { APP_DISPLAY_NAME } from "@shared/constants";
const APP_NAME = APP_DISPLAY_NAME;
interface SetupWizardProps {
onComplete: () => void;
/** 可选:注入 Mock API 用于测试 */
mockApi?: MockDependenciesApi;
/** 可选:跳过依赖检测(模拟依赖已就绪) */
skipDependencyCheck?: boolean;
/** 可选:模拟已登录状态 */
mockLoggedIn?: boolean;
}
function SetupWizard({
onComplete,
mockApi,
skipDependencyCheck,
mockLoggedIn,
}: SetupWizardProps) {
const { lang } = useI18nLang();
const wizardSteps = useMemo(
() => [
{
key: 1,
title: t("Claw.Setup.basicConfig.title"),
icon: <SettingOutlined />,
},
{ key: 2, title: t("Claw.Setup.login.title"), icon: <UserOutlined /> },
],
[lang],
);
const [dependenciesReady, setDependenciesReady] = useState<boolean | null>(
null,
);
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(true);
const [step1Config, setStep1Config] =
useState<Step1Config>(DEFAULT_STEP1_CONFIG);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [completed, setCompleted] = useState(false);
const [domain, setDomain] = useState("");
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState("");
const [retryCooldown, setRetryCooldown] = useState(0);
const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false);
const [checkingAuth, setCheckingAuth] = useState(false);
const [quickIniting, setQuickIniting] = useState(false);
const quickInitAttempted = useRef(false);
useEffect(() => {
const init = async () => {
try {
// 如果指定跳过依赖检查,直接进入配置步骤
if (skipDependencyCheck) {
setDependenciesReady(true);
const state = await setupService.getSetupState();
if (state.completed) {
setCompleted(true);
onCompleteRef.current();
} else {
setCurrentStep(state.step1Completed ? 2 : 1);
}
const config = await setupService.getStep1Config();
setStep1Config(config);
setLoading(false);
return;
}
// 检测所有依赖状态(支持 mock API
const checkAllApi =
mockApi?.checkAll || window.electronAPI?.dependencies.checkAll;
const result = await checkAllApi?.();
const deps = result?.results || [];
const allInstalled = deps.every(
(d) => d.status === "installed" || d.status === "bundled",
);
console.log(
"[SetupWizard] Dependency check:",
deps.map((d) => `${d.name}:${d.status}`).join(", "),
mockApi ? "(mock)" : "(real)",
);
if (allInstalled) {
// 所有依赖就绪,跳过安装阶段
setDependenciesReady(true);
// 加载已保存的步骤
const state = await setupService.getSetupState();
if (state.completed) {
setCompleted(true);
onComplete();
} else {
// 依赖已就绪、setup 未完成 → 优先尝试快捷初始化
if (!quickInitAttempted.current) {
quickInitAttempted.current = true;
try {
const qiConfig =
await window.electronAPI?.quickInit.getConfig();
if (qiConfig) {
// 先加载已保存的配置(用于回退时显示)
const savedConfig = await setupService.getStep1Config();
setStep1Config(savedConfig);
setLoading(false);
performQuickInit(qiConfig);
return; // performQuickInit 内部处理 loading 和 step
}
} catch (error) {
console.warn(
"[SetupWizard] Failed to read quick init config on startup:",
error,
);
}
}
setCurrentStep(state.step1Completed ? 2 : 1);
}
} else {
// 有依赖缺失,进入安装流程
setDependenciesReady(false);
}
// 加载已保存的配置
const config = await setupService.getStep1Config();
setStep1Config(config);
} catch (error) {
console.error("[SetupWizard] Initialization failed:", error);
setDependenciesReady(false);
} finally {
setLoading(false);
}
};
init();
}, []);
/**
* Quick Init: 使用预置配置自动完成初始化
* 失败时回退到正常向导流程
*/
const performQuickInit = useCallback(
async (config: QuickInitConfig) => {
setQuickIniting(true);
try {
// 1. 保存 step1 配置
const step1: Step1Config = {
serverHost: normalizeServerHost(config.serverHost),
agentPort: config.agentPort,
fileServerPort: config.fileServerPort,
workspaceDir: config.workspaceDir,
};
await setupService.saveStep1Config(step1);
setStep1Config(step1);
// 2. 预存 savedKey 到 DB
const domain = normalizeServerHost(config.serverHost);
await window.electronAPI?.settings.set(
AUTH_KEYS.SAVED_KEY,
config.savedKey,
);
if (config.username) {
try {
const domainKey = `${AUTH_KEYS.SAVED_KEYS_PREFIX}${new URL(domain).hostname}_${config.username}`;
await window.electronAPI?.settings.set(domainKey, config.savedKey);
} catch {
// domain 解析失败时跳过域名级 savedKey 存储
}
}
// 3. 调用 loginAndRegisterpassword 传空字符串,函数内部从 DB 取 savedKey
await loginAndRegister(config.username, "", {
suppressToast: true,
domain,
});
// 4. 完成 step2 + setup
await setupService.completeStep2();
await setupService.completeSetup();
// 5. 触发完成
setQuickIniting(false);
setCompleted(true);
setTimeout(() => onComplete(), 1000);
} catch (error) {
console.error(
"[SetupWizard] Quick init failed, falling back to manual wizard:",
error,
);
setQuickIniting(false);
// step1 可能已保存,从 step1 或 step2 继续
const state = await setupService.getSetupState();
setCurrentStep(state.step1Completed ? 2 : 1);
}
},
[onComplete],
);
const handleDepsComplete = useCallback(async () => {
setDependenciesReady(true);
// 检查是否有快捷配置
if (!quickInitAttempted.current) {
quickInitAttempted.current = true;
try {
const config = await window.electronAPI?.quickInit.getConfig();
if (config) {
performQuickInit(config);
return;
}
} catch (error) {
console.warn("[SetupWizard] Failed to read quick init config:", error);
}
}
setCurrentStep(1);
}, [performQuickInit]);
// Check login status when entering step 2
const checkLoginStatus = useCallback(async () => {
setCheckingAuth(true);
try {
const auth = await getCurrentAuth();
if (auth.isLoggedIn && auth.userInfo) {
setIsAlreadyLoggedIn(true);
setDomain(auth.userInfo.currentDomain || "");
}
if (auth.username) {
setUsername(auth.username);
}
} catch (error) {
console.error("[SetupWizard] Failed to check login status:", error);
} finally {
setCheckingAuth(false);
}
}, []);
useEffect(() => {
if (currentStep === 2) {
checkLoginStatus();
}
}, [currentStep, checkLoginStatus]);
// Retry cooldown timer
useEffect(() => {
if (retryCooldown <= 0) return;
const timer = setInterval(() => {
setRetryCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [retryCooldown]);
const handleStep1Submit = async () => {
if (!step1Config.fileServerPort) {
message.warning(t("Claw.Setup.basicConfig.fileServerPortRequired"));
return;
}
if (!step1Config.agentPort) {
message.warning(t("Claw.Setup.basicConfig.agentPortRequired"));
return;
}
if (!step1Config.workspaceDir) {
message.warning(t("Claw.Setup.basicConfig.workspaceDirRequired"));
return;
}
setLoading(true);
try {
await setupService.saveStep1Config(step1Config);
setCurrentStep(2);
message.success(t("Claw.Setup.basicConfig.saved"));
} catch (error) {
message.error(t("Claw.Setup.basicConfig.saveFailed"));
} finally {
setLoading(false);
}
};
const handleLogin = async () => {
if (!username || !password) {
message.warning(t("Claw.Setup.login.accountAndCodeRequired"));
return;
}
const loginDomain = domain || step1Config.serverHost;
if (!loginDomain) {
message.warning(t("Claw.Setup.login.domainRequired"));
return;
}
setLoginLoading(true);
setLoginError("");
try {
await loginAndRegister(username, password, {
suppressToast: true,
domain: loginDomain,
});
setLoginError("");
await setupService.completeStep2();
await setupService.completeSetup();
setCompleted(true);
message.success(t("Claw.Setup.login.success"));
setTimeout(() => onComplete(), 2000);
} catch (error: any) {
const errorMessage = getAuthErrorMessage(error);
message.error(errorMessage);
setLoginError(errorMessage);
setPassword("");
setRetryCooldown(3);
} finally {
setLoginLoading(false);
}
};
const handleContinueLoggedIn = async () => {
try {
await setupService.completeStep2();
await setupService.completeSetup();
setCompleted(true);
setTimeout(() => onComplete(), 1000);
} catch {
onComplete();
}
};
const handleLogout = async () => {
try {
await logout();
setIsAlreadyLoggedIn(false);
setDomain("");
} catch {
message.error(t("Claw.Setup.login.logoutFailed"));
}
};
const handleSelectWorkspaceDir = async () => {
const result = await window.electronAPI?.dialog.openDirectory(
t("Claw.Settings.workspace.selectDir"),
);
if (result?.success && result.path) {
setStep1Config({ ...step1Config, workspaceDir: result.path });
}
};
const handleStepClick = useCallback(
(step: number) => {
if (step < currentStep - 1) {
setCurrentStep(step + 1);
}
},
[currentStep],
);
const renderStepContent = () => {
if (completed) {
return (
<Result
icon={
<CheckCircleOutlined style={{ color: "var(--color-success)" }} />
}
title={t("Claw.Setup.completed.title")}
subTitle={t("Claw.Setup.completed.subTitle")}
extra={<Spin size="small" />}
/>
);
}
switch (currentStep) {
case 1:
return (
<Form layout="vertical">
<Alert
message={t("Claw.Setup.basicConfig.title")}
description={t("Claw.Setup.basicConfig.description")}
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
<Form.Item
label={t("Claw.Setup.basicConfig.fileServerPort")}
required
>
<InputNumber
value={step1Config.fileServerPort}
onChange={(value) =>
setStep1Config({
...step1Config,
fileServerPort: value as number,
})
}
style={{ width: "100%" }}
min={1024}
max={65535}
/>
</Form.Item>
<Form.Item label={t("Claw.Setup.basicConfig.agentPort")} required>
<InputNumber
value={step1Config.agentPort}
onChange={(value) =>
setStep1Config({ ...step1Config, agentPort: value as number })
}
style={{ width: "100%" }}
min={1024}
max={65535}
/>
</Form.Item>
<Form.Item
label={t("Claw.Setup.basicConfig.workspaceDir")}
required
validateStatus={!step1Config.workspaceDir ? "error" : ""}
help={
!step1Config.workspaceDir
? t("Claw.Setup.basicConfig.workspaceDirRequired")
: ""
}
>
<Space.Compact style={{ width: "100%" }}>
<Input
value={step1Config.workspaceDir}
readOnly
placeholder={t(
"Claw.Setup.basicConfig.workspaceDirPlaceholder",
)}
style={{ cursor: "pointer" }}
onClick={handleSelectWorkspaceDir}
/>
<Button
icon={<FolderOpenOutlined />}
onClick={handleSelectWorkspaceDir}
>
{t("Claw.Setup.basicConfig.select")}
</Button>
</Space.Compact>
</Form.Item>
<Button
type="primary"
onClick={handleStep1Submit}
loading={loading}
block
>
{t("Claw.Setup.basicConfig.nextStep")}
</Button>
</Form>
);
case 2:
if (checkingAuth) {
return (
<div
style={{
minHeight: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Spin size="small" />
</div>
);
}
if (isAlreadyLoggedIn) {
return (
<Result
icon={
<CheckCircleOutlined
style={{ color: "var(--color-success)" }}
/>
}
title={t("Claw.Setup.login.alreadyLoggedIn")}
subTitle={t("Claw.Setup.login.currentDomain", {
domain: domain || "-",
})}
extra={
<div
style={{ display: "flex", gap: 8, justifyContent: "center" }}
>
<Button onClick={handleLogout} size="small">
{t("Claw.Setup.login.logout")}
</Button>
<Button type="primary" onClick={handleContinueLoggedIn}>
{t("Claw.Setup.login.nextStep")}
</Button>
</div>
}
style={{ padding: "16px 0" }}
/>
);
}
return (
<Form layout="vertical">
{loginError && (
<Alert
message={t("Claw.Setup.login.failed")}
description={
<Text
copyable={{ text: loginError }}
style={{
fontSize: 12,
color: "var(--color-text-secondary)",
}}
>
{loginError}
</Text>
}
type="error"
showIcon
style={{ marginBottom: 12 }}
/>
)}
<Form.Item label={t("Claw.Setup.login.domain")} required>
<Input
prefix={<GlobalOutlined />}
value={domain || step1Config.serverHost}
onChange={(e) => setDomain(e.target.value)}
placeholder={t("Claw.Setup.login.domainPlaceholder")}
autoComplete="off"
spellCheck={false}
/>
</Form.Item>
<Form.Item label={t("Claw.Setup.login.account")} required>
<Input
prefix={<UserOutlined />}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("Claw.Setup.login.accountPlaceholder")}
autoComplete="username"
/>
</Form.Item>
<Form.Item label={t("Claw.Setup.login.code")} required>
<Input.Password
prefix={<LockOutlined />}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("Claw.Setup.login.codePlaceholder")}
autoComplete="current-password"
/>
</Form.Item>
<Space style={{ width: "100%" }} direction="vertical">
<Space>
<Button onClick={() => setCurrentStep(1)}>
{t("Claw.Setup.login.prevStep")}
</Button>
<Button
type="primary"
onClick={handleLogin}
loading={loginLoading}
disabled={retryCooldown > 0}
>
{retryCooldown > 0
? t("Claw.Setup.login.pleaseWait", {
seconds: retryCooldown,
})
: t("Claw.Setup.login.loginButton")}
</Button>
</Space>
<div style={{ textAlign: "center" }}>
<Text
style={{ fontSize: 11, color: "var(--color-text-tertiary)" }}
>
{t("Claw.Setup.login.supportMultipleAccountTypes")}
</Text>
</div>
</Space>
</Form>
);
default:
return null;
}
};
// 初始加载中
if (loading) {
return (
<div style={styles.container}>
<div style={styles.center}>
<Spin size="small" />
</div>
</div>
);
}
// 阶段 1: 依赖检测/安装
if (!dependenciesReady) {
return (
<div style={styles.container}>
<div style={styles.header}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
justifyContent: "center",
marginBottom: 4,
}}
>
<span style={{ fontSize: 15, fontWeight: 600 }}>{APP_NAME}</span>
</div>
<div
style={{
fontSize: 12,
color: "var(--color-text-tertiary)",
textAlign: "center",
}}
>
{t("Claw.Setup.dependencies.checking")}
</div>
</div>
<div style={styles.content}>
<SetupDependencies
onComplete={handleDepsComplete}
mockApi={mockApi}
/>
</div>
<div style={styles.footer}>
<Text style={{ fontSize: 11, color: "var(--color-text-tertiary)" }}>
{APP_NAME}
</Text>
</div>
</div>
);
}
// Quick Init 进行中
if (quickIniting) {
return (
<div style={styles.container}>
<div style={styles.center}>
<Space direction="vertical" align="center">
<Spin size="default" />
<Text
style={{ fontSize: 13, color: "var(--color-text-secondary)" }}
>
{t("Claw.Setup.quickInit.configuring")}
</Text>
</Space>
</div>
</div>
);
}
// 阶段 2-4: 配置步骤
return (
<div style={styles.container}>
<div style={styles.header}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
justifyContent: "center",
marginBottom: 4,
}}
>
<span style={{ fontSize: 15, fontWeight: 600 }}>
{t("Claw.Setup.wizardTitle")}
</span>
</div>
<div style={{ fontSize: 12, color: "#a1a1aa", textAlign: "center" }}>
{t("Claw.Setup.wizardSubtitle")}
</div>
</div>
<div style={{ maxWidth: 480, margin: "0 auto 12px", padding: "0 8px" }}>
<Steps
current={currentStep - 1}
size="small"
onChange={handleStepClick}
items={wizardSteps.map((step) => ({
title: step.title,
icon: step.icon,
disabled: step.key > currentStep,
}))}
/>
</div>
<div style={styles.content}>{renderStepContent()}</div>
<div style={styles.footer}>
{/* 版本号由 Vite 在构建时从 crates/agent-electron-client/package.json 的 version 注入;
修改版本后需重启开发服务器或重新打包才能生效 */}
<Text style={{ fontSize: 11, color: "#a1a1aa" }}>
{APP_NAME} v{__APP_VERSION__} · {t("Claw.Setup.footer.autoSaved")}
</Text>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: "flex",
flexDirection: "column",
height: "100vh",
background: "var(--color-bg-layout)",
padding: 16,
},
center: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
},
header: {
marginBottom: 12,
},
content: {
flex: 1,
maxWidth: 640,
width: "100%",
margin: "0 auto",
overflowY: "auto",
background: "var(--color-bg-container)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: 20,
},
footer: {
textAlign: "center",
marginTop: 8,
},
};
export default SetupWizard;

View File

@@ -0,0 +1,954 @@
/*
* QimingClaw - 现代简洁全局样式
* 基于 Ant Design, 风格参考 shadcn/ui
* 支持亮色/暗色主题
*/
/* ========== CSS 变量 - 亮色主题 ========== */
:root,
[data-theme="light"] {
--color-primary: #18181b;
--color-primary-hover: #27272a;
--color-primary-active: #3f3f46;
--color-success: #16a34a;
--color-error: #EF4444;
--color-warning: #F59E0B;
--color-info: #3B82F6;
--color-success-bg: #f0fdf4;
--color-error-bg: #fef2f2;
--color-warning-bg: #fffbeb;
--color-bg-layout: #F8F9FA;
--color-bg-container: #ffffff;
--color-bg-elevated: #ffffff;
--color-bg-header: #ffffff;
--color-bg-sider: #ffffff;
--color-bg-section: #ffffff;
--color-bg-section-header: #F9FAFB;
--color-border: #E5E7EB;
--color-border-secondary: #F3F4F6;
--color-text: #18181b;
--color-text-secondary: #6B7280;
--color-text-tertiary: #9CA3AF;
--color-text-quaternary: #D1D5DB;
--color-icon: #6B7280;
--color-divider: #F3F4F6;
--border-radius: 8px;
--border-radius-lg: 10px;
--border-radius-sm: 6px;
}
/* ========== CSS 变量 - 暗色主题 ========== */
[data-theme="dark"] {
--color-primary: #fafafa;
--color-primary-hover: #e4e4e7;
--color-primary-active: #d4d4d8;
--color-success: #22c55e;
--color-error: #EF4444;
--color-warning: #F59E0B;
--color-info: #3B82F6;
--color-success-bg: #052e16;
--color-error-bg: #450a0a;
--color-warning-bg: #422006;
--color-bg-layout: #09090b;
--color-bg-container: #18181b;
--color-bg-elevated: #27272a;
--color-bg-header: #18181b;
--color-bg-sider: #18181b;
--color-bg-section: #18181b;
--color-bg-section-header: #27272a;
--color-border: #27272a;
--color-border-secondary: #3f3f46;
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-tertiary: #71717a;
--color-text-quaternary: #52525b;
--color-icon: #a1a1aa;
--color-divider: #27272a;
--border-radius: 8px;
--border-radius-lg: 10px;
--border-radius-sm: 6px;
}
/* ========== 基础重置 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--color-text);
font-size: 13px;
line-height: 1.5;
background: var(--color-bg-layout);
transition: background-color 0.2s ease, color 0.2s ease;
}
/* ========== 布局 ========== */
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-layout);
}
.app-header {
height: 48px;
background: var(--color-bg-header);
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
z-index: 100;
border-bottom: 1px solid var(--color-border);
}
.app-header-logo {
display: flex;
align-items: center;
gap: 8px;
}
.app-header-title {
color: var(--color-text);
font-size: 13px;
font-weight: 600;
margin: 0 !important;
letter-spacing: -0.01em;
}
.app-header-update-tag {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 500;
padding: 1px 8px;
border-radius: 10px;
line-height: 18px;
white-space: nowrap;
transition: opacity 0.2s, transform 0.15s;
}
.app-header-update-tag[role="button"] {
cursor: pointer;
}
.app-header-update-tag[role="button"]:hover {
opacity: 0.85;
transform: scale(1.03);
}
.app-header-update-tag[role="button"]:active {
transform: scale(0.97);
}
.app-header-update-tag--available {
background: rgba(82, 196, 26, 0.12);
color: #52c41a;
border: 1px solid rgba(82, 196, 26, 0.25);
}
.app-header-update-tag--downloading {
background: rgba(22, 119, 255, 0.1);
color: #1677ff;
border: 1px solid rgba(22, 119, 255, 0.2);
}
.app-header-update-tag--install {
background: rgba(250, 173, 20, 0.12);
color: #faad14;
border: 1px solid rgba(250, 173, 20, 0.25);
}
.app-body {
flex: 1;
display: flex;
overflow: hidden;
}
.app-sider {
width: 140px;
background: var(--color-bg-sider);
overflow-y: auto;
flex-shrink: 0;
border-right: 1px solid var(--color-border);
padding: 4px 0;
}
/* 英文菜单文案更长,单独加宽侧边栏,避免文本被截断 */
.app-sider.app-sider-en {
width: 160px;
}
.app-sider .ant-menu {
border-right: 0;
}
.app-content {
flex: 1;
padding: 20px 24px;
background: var(--color-bg-layout);
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0;
transition: background-color 0.2s ease;
}
/* Full-width mode: no padding (used when webview is active) */
.app-content.app-content-fullwidth {
padding: 0;
overflow: hidden;
}
/* ========== 滚动条 ========== */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-text-quaternary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* ========== 侧边栏菜单 ========== */
/* Note: Selected colors handled by ConfigProvider in theme.ts */
.app-sider .ant-menu-light {
background: transparent;
border-inline-end: none;
}
.app-sider .ant-menu-light .ant-menu-item {
height: 32px;
line-height: 32px;
margin: 1px 6px;
padding: 0 10px !important;
font-size: 13px;
font-weight: 400;
transition: all 0.15s ease;
}
.app-sider .ant-menu-light .ant-menu-item:hover {
background: #F3F4F6;
color: #18181b;
}
.app-sider .ant-menu-light .ant-menu-item .anticon {
font-size: 14px;
}
/* ========== 按钮 ========== */
/* Note: Primary button colors handled by ConfigProvider in theme.ts */
.ant-btn {
transition: all 0.15s ease;
font-weight: 500;
box-shadow: none;
}
.ant-btn-default {
border-color: var(--color-border);
color: var(--color-text-secondary);
background: var(--color-bg-container);
}
.ant-btn-default:not(:disabled):hover {
border-color: var(--color-border-secondary);
color: var(--color-text);
background: var(--color-bg-elevated);
}
.ant-btn-text:not(:disabled):hover {
background: var(--color-bg-elevated);
}
/* 小号按钮更紧凑 */
.ant-btn-sm {
font-size: 12px;
padding: 0 8px;
height: 26px;
}
/* ========== 卡片 ========== */
.ant-card {
border: 1px solid #E5E7EB;
border-radius: 10px;
}
.ant-card-head {
min-height: auto;
padding: 10px 16px;
border-bottom: 1px solid #F3F4F6;
font-size: 13px;
}
.ant-card-head-title {
font-weight: 500;
font-size: 13px;
color: #18181b;
}
.ant-card-body {
padding: 16px;
}
.ant-card-small .ant-card-head {
padding: 8px 12px;
}
.ant-card-small .ant-card-body {
padding: 12px;
}
/* ========== 标签 - 极简 ========== */
.ant-tag {
margin-right: 0;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
line-height: 20px;
border: 1px solid transparent;
}
.ant-tag-success,
.ant-tag-green {
background: #f0fdf4;
color: #15803d;
border-color: #dcfce7;
}
.ant-tag-error,
.ant-tag-red {
background: #FEF2F2;
color: #EF4444;
border-color: #FEE2E2;
}
.ant-tag-warning,
.ant-tag-orange {
background: #FFFBEB;
color: #D97706;
border-color: #FEF3C7;
}
.ant-tag-processing {
background: #F3F4F6;
color: #6B7280;
border-color: #E5E7EB;
}
.ant-tag-default {
background: #F3F4F6;
color: #6B7280;
border-color: #E5E7EB;
}
.ant-tag-blue {
background: #EFF6FF;
color: #2563EB;
border-color: #DBEAFE;
}
.ant-tag-purple {
background: #F5F3FF;
color: #7C3AED;
border-color: #EDE9FE;
}
.ant-tag-cyan {
background: #ECFEFF;
color: #0891B2;
border-color: #CFFAFE;
}
/* ========== 输入框 ========== */
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number {
border-radius: 8px;
border-color: #E5E7EB;
}
.ant-input:focus,
.ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper-focused,
.ant-input-number:focus,
.ant-input-number-focused {
border-color: #a1a1aa;
box-shadow: 0 0 0 2px rgba(24, 24, 27, 0.05);
}
.ant-input:hover,
.ant-input-affix-wrapper:hover,
.ant-input-number:hover {
border-color: #D1D5DB;
}
.ant-input::placeholder {
color: #9CA3AF;
}
/* ========== 表单 ========== */
.ant-form-item-label > label {
font-weight: 500;
font-size: 12px;
color: #6B7280;
}
.ant-form-item {
margin-bottom: 16px;
}
/* ========== 列表 ========== */
.ant-list-item {
transition: background-color 0.15s ease;
border-radius: 8px;
padding: 8px 12px;
}
.ant-list-item-meta-title {
font-size: 13px;
font-weight: 500;
color: #18181b;
}
.ant-list-item-meta-description {
font-size: 12px;
color: #6B7280;
}
/* ========== 警告框 - 更柔和 ========== */
.ant-alert {
border-radius: 8px;
border-width: 1px;
padding: 8px 12px;
font-size: 12px;
}
.ant-alert-success {
background: #f0fdf4;
border-color: #dcfce7;
}
.ant-alert-warning {
background: #FFFBEB;
border-color: #FEF3C7;
}
.ant-alert-error {
background: #FEF2F2;
border-color: #FEE2E2;
}
.ant-alert-info {
background: #F9FAFB;
border-color: #E5E7EB;
}
.ant-alert-message {
font-weight: 500;
color: var(--color-text);
font-size: 12px;
}
.ant-alert-description {
font-size: 12px;
color: var(--color-text-secondary);
}
/* ========== 描述列表 ========== */
.ant-descriptions-item-label {
color: var(--color-text-secondary);
font-size: 12px;
}
.ant-descriptions-item-content {
font-size: 13px;
color: var(--color-text);
}
/* ========== 开关 ========== */
/* Note: Switch colors handled by ConfigProvider in theme.ts */
/* ========== 选择器 ========== */
.ant-select-selector {
border-radius: 8px;
border-color: var(--color-border);
}
/* ========== 步骤条 ========== */
/* Note: Step colors handled by ConfigProvider in theme.ts */
/* ========== 进度条 ========== */
.ant-progress-text {
font-size: 11px;
color: #6B7280;
}
/* ========== Tabs ========== */
/* Note: Tab colors handled by ConfigProvider in theme.ts */
.ant-tabs .ant-tabs-tab {
font-size: 13px;
padding: 8px 0;
transition: color 0.15s ease;
}
/* ========== Modal ========== */
.ant-modal-content {
border-radius: 10px;
padding: 0;
}
.ant-modal-header {
padding: 16px 20px 12px;
}
.ant-modal-title {
font-weight: 500;
font-size: 14px;
}
.ant-modal-body {
padding: 0 20px 16px;
font-size: 13px;
color: #52525b;
}
.ant-modal-footer {
padding: 12px 20px 16px;
border-top: 1px solid #F3F4F6;
}
/* ========== 徽章 ========== */
.ant-badge-status-text {
font-size: 12px;
color: #6B7280;
}
/* ========== Divider ========== */
.ant-divider {
border-color: #F3F4F6;
margin: 12px 0;
}
/* ========== Avatar ========== */
.ant-avatar {
border: none;
}
/* ========== Result ========== */
.ant-result-title {
font-size: 16px;
font-weight: 500;
color: #18181b;
}
.ant-result-subtitle {
font-size: 13px;
color: #71717a;
}
/* ========== 加载页面 ========== */
.app-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: #F8F9FA;
}
.app-loading-text {
margin-top: 16px;
color: #6B7280;
font-size: 13px;
}
.app-loading .ant-spin-dot-item {
background-color: #18181b;
}
/* ========== 页面通用 ========== */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 15px;
font-weight: 600;
color: #18181b;
margin: 0;
}
.page-subtitle {
font-size: 12px;
color: #6B7280;
margin-top: 2px;
}
/* 简洁的状态行 */
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #F3F4F6;
}
.status-row:last-child {
border-bottom: none;
}
.status-row-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #3f3f46;
}
.status-row-value {
font-size: 13px;
color: #18181b;
}
/* 简洁信息网格 */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 24px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
}
.info-label {
font-size: 12px;
color: #6B7280;
}
.info-value {
font-size: 13px;
color: #18181b;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
/* section 分隔 */
.section {
margin-bottom: 20px;
background: #ffffff;
border-radius: 10px;
}
.section-title {
font-size: 12px;
font-weight: 500;
color: #6B7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
/* ========== 消息气泡 ========== */
.message-user {
background: #18181b;
color: #fff;
padding: 12px 16px;
border-radius: 12px;
max-width: 70%;
margin-left: auto;
}
.message-assistant {
background: #F3F4F6;
color: #18181b;
padding: 12px 16px;
border-radius: 12px;
max-width: 70%;
}
/* ============================================
暗色主题覆盖 (Dark Theme Overrides)
============================================ */
[data-theme="dark"] {
/* 侧边栏菜单 */
.app-sider .ant-menu-light .ant-menu-item:hover {
background: var(--color-bg-elevated);
color: var(--color-text);
}
/* 按钮 */
.ant-btn-default {
border-color: var(--color-border);
color: var(--color-text-secondary);
background: var(--color-bg-container);
}
.ant-btn-default:not(:disabled):hover {
border-color: var(--color-border-secondary);
color: var(--color-text);
background: var(--color-bg-elevated);
}
.ant-btn-text:not(:disabled):hover {
background: var(--color-bg-elevated);
}
.ant-btn:disabled {
border-color: var(--color-border);
color: var(--color-text-quaternary);
background: rgba(255, 255, 255, 0.04);
}
/* 卡片 */
.ant-card {
border-color: var(--color-border);
}
.ant-card-head {
border-bottom-color: var(--color-border);
}
.ant-card-head-title {
color: var(--color-text);
}
/* 标签 */
.ant-tag-processing,
.ant-tag-default {
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
border-color: var(--color-border);
}
/* 输入框 */
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number {
border-color: var(--color-border);
background: var(--color-bg-container);
}
.ant-input:focus,
.ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper-focused,
.ant-input-number:focus,
.ant-input-number-focused {
border-color: var(--color-text-tertiary);
}
.ant-input:hover,
.ant-input-affix-wrapper:hover,
.ant-input-number:hover {
border-color: var(--color-border-secondary);
}
.ant-input::placeholder {
color: var(--color-text-quaternary);
}
/* 表单 */
.ant-form-item-label > label {
color: var(--color-text-secondary);
}
/* 列表 */
.ant-list-item-meta-title {
color: var(--color-text);
}
.ant-list-item-meta-description {
color: var(--color-text-tertiary);
}
/* 警告框 */
.ant-alert-info,
.ant-alert-success,
.ant-alert-warning,
.ant-alert-error {
background: var(--color-bg-elevated);
border-color: var(--color-border);
}
.ant-alert-message {
color: var(--color-text);
}
.ant-alert-description {
color: var(--color-text-secondary);
}
/* 描述列表 */
.ant-descriptions-item-label {
color: var(--color-text-tertiary);
}
.ant-descriptions-item-content {
color: var(--color-text);
}
/* 选择器 */
.ant-select-selector {
border-color: var(--color-border) !important;
background: var(--color-bg-container) !important;
}
.ant-select-dropdown {
background: var(--color-bg-elevated) !important;
}
.ant-select-item {
color: var(--color-text) !important;
}
.ant-select-item-option-active {
background: var(--color-bg-elevated) !important;
}
.ant-select-item-option-selected {
background: var(--color-bg-elevated) !important;
color: var(--color-text) !important;
}
.ant-select-item:hover {
background: var(--color-primary-hover) !important;
color: var(--color-text) !important;
}
/* 进度条 */
.ant-progress-text {
color: var(--color-text-secondary);
}
/* 开关 — darkAlgorithm + ConfigProvider token 已处理,无需额外覆盖 */
/* Modal */
.ant-modal-body {
color: var(--color-text-secondary);
}
.ant-modal-footer {
border-top-color: var(--color-border);
}
/* 徽章 */
.ant-badge-status-text {
color: var(--color-text-secondary);
}
/* Divider */
.ant-divider {
border-color: var(--color-border);
}
/* Result */
.ant-result-title {
color: var(--color-text);
}
.ant-result-subtitle {
color: var(--color-text-secondary);
}
/* 加载页面 */
.app-loading {
background: var(--color-bg-layout);
}
.app-loading-text {
color: var(--color-text-secondary);
}
.app-loading .ant-spin-dot-item {
background-color: var(--color-text);
}
/* 页面通用 */
.page-subtitle {
color: var(--color-text-secondary);
}
/* 状态行 */
.status-row {
border-bottom-color: var(--color-border);
}
.status-row-label {
color: var(--color-text-secondary);
}
.status-row-value {
color: var(--color-text);
}
/* 信息网格 */
.info-label {
color: var(--color-text-tertiary);
}
.info-value {
color: var(--color-text);
}
/* section */
.section {
background: var(--color-bg-section);
}
.section-title {
color: var(--color-text-tertiary);
}
/* 消息气泡 */
.message-user {
background: var(--color-primary);
color: var(--color-bg-container);
}
.message-assistant {
background: var(--color-bg-elevated);
color: var(--color-text);
}
}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/icon.png" />
<!-- 与 @shared/constants APP_DISPLAY_NAME 保持一致 -->
<title>QimingClaw</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom/client";
import { ConfigProvider, Spin } from "antd";
import enUS from "antd/locale/en_US";
import zhCN from "antd/locale/zh_CN";
import zhTW from "antd/locale/zh_TW";
import zhHK from "antd/locale/zh_HK";
import "./monaco/setupMonaco";
import App from "./App";
import i18n, { initSupportedLangs } from "./services/i18n"; // 初始化 i18next自动检测浏览器语言
import { initI18n, getCurrentLang } from "./services/core/i18n"; // 初始化自定义 i18n 服务
import { rootTheme } from "./styles/theme";
import "./index.css";
// i18n 就绪标志(模块级别,由 initI18n 设置)
let i18nReady = false;
const i18nReadyListeners: Array<(ready: boolean) => void> = [];
function onI18nReady(callback: (ready: boolean) => void) {
i18nReadyListeners.push(callback);
}
function notifyI18nReady(ready: boolean) {
i18nReady = ready;
i18nReadyListeners.forEach((cb) => cb(ready));
}
// 初始化 i18nAPI 驱动 + 本地缓存)- 尽快开始
Promise.all([initSupportedLangs(), initI18n()])
.then(async () => {
const lang = getCurrentLang();
try {
await i18n.changeLanguage(lang);
} catch {
// ignore i18next sync failure, keep app booting
}
notifyI18nReady(true);
})
.catch((error) => {
console.error(error);
notifyI18nReady(true);
});
// antd locale 映射
const antdLocales: Record<string, typeof zhCN> = {
en: enUS,
"en-us": enUS,
"en-gb": enUS,
zh: zhCN,
"zh-cn": zhCN,
"zh-hans": zhCN,
"zh-tw": zhTW,
"zh-hk": zhHK,
};
function resolveAntdLocale(lang: string) {
const normalized = String(lang || "").toLowerCase();
if (normalized.startsWith("zh-tw")) return zhTW;
if (normalized.startsWith("zh-hk")) return zhHK;
if (normalized.startsWith("zh")) return zhCN;
if (normalized.startsWith("en")) return enUS;
const exactMatch = antdLocales[normalized];
return exactMatch || zhCN;
}
function toHtmlLang(lang: string): string {
const normalized = String(lang || "").toLowerCase();
if (normalized.startsWith("zh-tw")) return "zh-TW";
if (normalized.startsWith("zh-hk")) return "zh-HK";
if (normalized.startsWith("zh")) return "zh-CN";
return "en-US";
}
function resolveBootLoadingText(): string {
// 启动早期优先使用本地已知语言(缓存/current i18n/browser未命中统一回退英文。
const candidates = [
getCurrentLang(),
i18n.language,
typeof navigator !== "undefined" ? navigator.language : "",
];
const lang = String(candidates.find((v) => v) || "en-US").toLowerCase();
if (lang.startsWith("zh-tw") || lang.startsWith("zh-hk")) return "載入中...";
if (lang.startsWith("zh")) return "加载中...";
return "Loading...";
}
function Main() {
const [antdLocale, setAntdLocale] = useState(zhCN);
const [ready, setReady] = useState(i18nReady);
// 动态更新 antd locale所有渲染都会执行
useEffect(() => {
const updateLocale = () => {
const lang = i18n.language || "zh-CN";
document.documentElement.lang = toHtmlLang(lang);
setAntdLocale(resolveAntdLocale(lang));
};
updateLocale();
// 监听语言变化
i18n.on("languageChanged", updateLocale);
return () => {
i18n.off("languageChanged", updateLocale);
};
}, []);
// 订阅 i18n 就绪状态
useEffect(() => {
if (i18nReady) {
setReady(true);
return;
}
onI18nReady(setReady);
}, []);
// i18n 未就绪时显示加载状态(所有 hooks 已经在上面执行完毕)
if (!ready) {
return (
<ConfigProvider locale={zhCN}>
<div className="app-loading">
<Spin size="large" />
<div className="app-loading-text">{resolveBootLoadingText()}</div>
</div>
</ConfigProvider>
);
}
return (
<ConfigProvider locale={antdLocale} theme={rootTheme}>
<App />
</ConfigProvider>
);
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Main />
</React.StrictMode>,
);

View File

@@ -0,0 +1,14 @@
import { loader } from "@monaco-editor/react";
function resolveVsBaseUrl(): string {
// dev: http://localhost:60173/
// prod: file://.../dist/index.html (vite base './')
// 统一用 URL 解析,避免 Windows 路径分隔符影响
return new URL("./monaco/vs", window.location.href).toString();
}
loader.config({
paths: {
vs: resolveVsBaseUrl(),
},
});

View File

@@ -0,0 +1 @@
export * from './agents/agentRunner';

View File

@@ -0,0 +1,235 @@
import {
LOCAL_HOST_URL,
DEFAULT_AGENT_RUNNER_PORT,
DEFAULT_LANPROXY_PORT,
DEFAULT_ANTHROPIC_API_URL,
DEFAULT_AI_MODEL,
} from '@shared/constants';
export interface AgentRunnerConfig {
enabled: boolean;
binPath: string;
backendPort: number;
proxyPort: number;
apiKey: string;
apiBaseUrl: string;
defaultModel: string;
}
export const defaultAgentRunnerConfig: AgentRunnerConfig = {
enabled: false,
binPath: 'qiming-agent-core',
backendPort: DEFAULT_AGENT_RUNNER_PORT,
proxyPort: DEFAULT_LANPROXY_PORT,
apiKey: '',
apiBaseUrl: DEFAULT_ANTHROPIC_API_URL,
defaultModel: DEFAULT_AI_MODEL,
};
export interface AgentRunnerStatus {
running: boolean;
pid?: number;
backendUrl?: string;
proxyUrl?: string;
error?: string;
}
export interface ChatRequest {
messages: { role: 'user' | 'assistant'; content: string }[];
model?: string;
maxTokens?: number;
}
export interface ChatResponse {
content: string;
type: 'message' | 'error';
}
class AgentRunnerManager {
private config: AgentRunnerConfig = { ...defaultAgentRunnerConfig };
private status: AgentRunnerStatus = { running: false };
getConfig(): AgentRunnerConfig {
return { ...this.config };
}
setConfig(config: Partial<AgentRunnerConfig>) {
this.config = { ...this.config, ...config };
}
getStatus(): AgentRunnerStatus {
return { ...this.status };
}
getBackendUrl(): string {
return `${LOCAL_HOST_URL}:${this.config.backendPort}`;
}
async loadConfig(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get('agent_runner_config');
if (saved) {
this.config = { ...defaultAgentRunnerConfig, ...(saved as AgentRunnerConfig) };
}
} catch (error) {
console.error('Failed to load agent runner config:', error);
}
}
async saveConfig(): Promise<void> {
try {
await window.electronAPI?.settings.set('agent_runner_config', this.config);
} catch (error) {
console.error('Failed to save agent runner config:', error);
}
}
// Start agent runner via IPC
async start(): Promise<{ success: boolean; error?: string }> {
if (this.status.running) {
return { success: true };
}
try {
const result = await window.electronAPI?.agentRunner.start({
binPath: this.config.binPath,
backendPort: this.config.backendPort,
proxyPort: this.config.proxyPort,
apiKey: this.config.apiKey,
apiBaseUrl: this.config.apiBaseUrl,
defaultModel: this.config.defaultModel,
});
if (result?.success) {
this.status.running = true;
this.status.backendUrl = this.getBackendUrl();
}
return result || { success: false, error: 'IPC failed' };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Stop agent runner via IPC
async stop(): Promise<{ success: boolean; error?: string }> {
if (!this.status.running) {
return { success: true };
}
try {
const result = await window.electronAPI?.agentRunner.stop();
if (result?.success) {
this.status.running = false;
}
return result || { success: false, error: 'IPC failed' };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Check status via IPC
async checkStatus(): Promise<AgentRunnerStatus> {
try {
const status = await window.electronAPI?.agentRunner.status();
this.status = status || { running: false };
return this.status;
} catch (error) {
return { running: false, error: String(error) };
}
}
// Send chat request to the agent runner HTTP API
async chat(request: ChatRequest): Promise<ChatResponse> {
if (!this.status.running) {
return { content: 'Agent Runner is not running', type: 'error' };
}
try {
const response = await fetch(`${this.getBackendUrl()}/computer/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
messages: request.messages,
model: request.model || this.config.defaultModel,
max_tokens: request.maxTokens || 4096,
}),
});
if (!response.ok) {
const error = await response.text();
return { content: `Error: ${response.status} - ${error}`, type: 'error' };
}
const data = await response.json();
return { content: data.content || data.message?.content || '', type: 'message' };
} catch (error) {
return { content: `Error: ${error}`, type: 'error' };
}
}
// Stream chat via SSE
async *streamChat(request: ChatRequest): AsyncGenerator<string> {
if (!this.status.running) {
yield 'Error: Agent Runner is not running';
return;
}
const response = await fetch(`${this.getBackendUrl()}/computer/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
messages: request.messages,
model: request.model || this.config.defaultModel,
max_tokens: request.maxTokens || 4096,
stream: true,
}),
});
if (!response.ok) {
yield `Error: ${response.status}`;
return;
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) return;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
if (parsed.content) {
yield parsed.content;
}
} catch {
// Not JSON, might be plain text
yield data;
}
}
}
}
} finally {
reader.releaseLock();
}
}
}
export const agentRunnerManager = new AgentRunnerManager();

View File

@@ -0,0 +1,385 @@
/**
* Permissions Service - 权限管理服务
*
* 功能:
* - 权限请求管理
* - 权限规则配置
* - 权限持久化
* - 会话级别授权
*/
import * as fs from "fs";
import * as path from "path";
import { APP_DATA_DIR_NAME } from "@shared/constants";
import { t } from "../core/i18n";
export type PermissionType =
| "tool"
| "command"
| "file"
| "network"
| "sandbox";
export type PermissionAction = "allow" | "deny" | "prompt";
export interface PermissionRequest {
id: string;
type: PermissionType;
title: string;
description: string;
details: {
tool?: string;
command?: string;
file?: string;
url?: string;
args?: string[];
env?: Record<string, string>;
sandbox?: boolean;
};
sessionId: string;
timestamp: number;
status: "pending" | "approved" | "denied";
}
export interface PermissionRule {
id: string;
name: string;
pattern: string;
action: PermissionAction;
type?: PermissionType;
expiresAt?: number;
createdAt: number;
}
export interface PermissionConfig {
defaultAction: PermissionAction;
sessionTimeout: number; // 分钟
rules: PermissionRule[];
}
// 默认权限配置
const DEFAULT_CONFIG: PermissionConfig = {
defaultAction: "prompt",
sessionTimeout: 30,
rules: [
// 允许的工具
{
id: "1",
name: t("Claw.PermissionRules.defaultToolRead"),
pattern: "tool:read",
action: "allow",
type: "tool",
createdAt: 0,
},
{
id: "2",
name: t("Claw.PermissionRules.defaultToolEdit"),
pattern: "tool:edit",
action: "prompt",
type: "tool",
createdAt: 0,
},
{
id: "3",
name: t("Claw.PermissionRules.defaultBash"),
pattern: "command:bash",
action: "prompt",
type: "command",
createdAt: 0,
},
{
id: "4",
name: t("Claw.PermissionRules.defaultNetwork"),
pattern: "network:http",
action: "prompt",
type: "network",
createdAt: 0,
},
{
id: "5",
name: t("Claw.PermissionRules.defaultFileRead"),
pattern: "file:read",
action: "allow",
type: "file",
createdAt: 0,
},
{
id: "6",
name: t("Claw.PermissionRules.defaultFileWrite"),
pattern: "file:write",
action: "prompt",
type: "file",
createdAt: 0,
},
],
};
class PermissionManager {
private config: PermissionConfig;
private pendingRequests: Map<string, PermissionRequest> = new Map();
private sessionApprovedTools: Map<string, Map<string, boolean>> = new Map();
private configPath: string;
constructor() {
// 配置文件路径
const home = process.env.HOME || process.env.USERPROFILE || "";
this.configPath = path.join(home, APP_DATA_DIR_NAME, "permissions.json");
this.config = this.loadConfig();
}
/**
* 加载配置
*/
private loadConfig(): PermissionConfig {
try {
if (fs.existsSync(this.configPath)) {
const data = fs.readFileSync(this.configPath, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
}
} catch (error) {
console.error("[Permissions] Load config failed:", error);
}
return { ...DEFAULT_CONFIG };
}
/**
* 保存配置
*/
private saveConfig(): void {
try {
const dir = path.dirname(this.configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
} catch (error) {
console.error("[Permissions] Save config failed:", error);
}
}
/**
* 检查权限
*/
checkPermission(
request: Omit<PermissionRequest, "id" | "timestamp" | "status">,
): {
allowed: boolean;
requiresPrompt: boolean;
rule?: PermissionRule;
} {
const key = this.getRuleKey(request);
// 1. 检查规则
for (const rule of this.config.rules) {
if (this.matchPattern(key, rule.pattern)) {
if (rule.action === "allow") {
return { allowed: true, requiresPrompt: false, rule };
}
if (rule.action === "deny") {
return { allowed: false, requiresPrompt: false, rule };
}
// prompt: 继续检查
}
}
// 2. 检查会话级别授权
const sessionApproved = this.sessionApprovedTools.get(request.sessionId);
if (sessionApproved?.has(key)) {
return { allowed: true, requiresPrompt: false };
}
// 3. 默认动作
if (this.config.defaultAction === "allow") {
return { allowed: true, requiresPrompt: false };
}
if (this.config.defaultAction === "deny") {
return { allowed: false, requiresPrompt: false };
}
// 4. 需要提示用户
return { allowed: false, requiresPrompt: true };
}
/**
* 生成规则 Key
*/
private getRuleKey(
request: Omit<PermissionRequest, "id" | "timestamp" | "status">,
): string {
const { type, details } = request;
switch (type) {
case "tool":
return `tool:${details.tool || "*"}`;
case "command":
return `command:${details.command || "*"}`;
case "file":
return `file:${details.file || "*"}`;
case "network":
return `network:${details.url || "*"}`;
case "sandbox":
return "sandbox:execute";
default:
return `${type}:*`;
}
}
/**
* 匹配模式
*/
private matchPattern(key: string, pattern: string): boolean {
// 支持通配符
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
return regex.test(key);
}
/**
* 创建权限请求
*/
createRequest(
request: Omit<PermissionRequest, "id" | "timestamp" | "status">,
): PermissionRequest {
const fullRequest: PermissionRequest = {
...request,
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 9),
timestamp: Date.now(),
status: "pending",
};
this.pendingRequests.set(fullRequest.id, fullRequest);
return fullRequest;
}
/**
* 批准请求
*/
approveRequest(requestId: string, alwaysAllow: boolean = false): boolean {
const request = this.pendingRequests.get(requestId);
if (!request) return false;
request.status = "approved";
// 如果选择"总是允许",添加到会话授权
if (alwaysAllow) {
const key = this.getRuleKey(request);
if (!this.sessionApprovedTools.has(request.sessionId)) {
this.sessionApprovedTools.set(request.sessionId, new Map());
}
this.sessionApprovedTools.get(request.sessionId)!.set(key, true);
}
return true;
}
/**
* 拒绝请求
*/
denyRequest(requestId: string): boolean {
const request = this.pendingRequests.get(requestId);
if (!request) return false;
request.status = "denied";
return true;
}
/**
* 获取待处理请求
*/
getPendingRequests(sessionId?: string): PermissionRequest[] {
const requests = Array.from(this.pendingRequests.values()).filter(
(r) => r.status === "pending",
);
if (sessionId) {
return requests.filter((r) => r.sessionId === sessionId);
}
return requests;
}
/**
* 添加规则
*/
addRule(rule: Omit<PermissionRule, "id" | "createdAt">): void {
const newRule: PermissionRule = {
...rule,
id: Date.now().toString(36),
createdAt: Date.now(),
};
this.config.rules.push(newRule);
this.saveConfig();
}
/**
* 删除规则
*/
removeRule(ruleId: string): void {
this.config.rules = this.config.rules.filter((r) => r.id !== ruleId);
this.saveConfig();
}
/**
* 获取规则列表
*/
getRules(): PermissionRule[] {
return [...this.config.rules];
}
/**
* 更新默认动作
*/
setDefaultAction(action: PermissionAction): void {
this.config.defaultAction = action;
this.saveConfig();
}
/**
* 清除会话授权
*/
clearSessionApproval(sessionId: string): void {
this.sessionApprovedTools.delete(sessionId);
}
/**
* 获取权限配置
*/
getConfig(): PermissionConfig {
return { ...this.config };
}
/**
* 更新权限配置
*/
updateConfig(config: Partial<PermissionConfig>): void {
this.config = { ...this.config, ...config };
this.saveConfig();
}
/**
* 导出配置
*/
exportConfig(): string {
return JSON.stringify(this.config, null, 2);
}
/**
* 导入配置
*/
importConfig(configJson: string): boolean {
try {
const imported = JSON.parse(configJson);
this.config = { ...DEFAULT_CONFIG, ...imported };
this.saveConfig();
return true;
} catch {
return false;
}
}
}
export const permissionManager = new PermissionManager();
export default permissionManager;

View File

@@ -0,0 +1,548 @@
/**
* Sandbox Manager - 跨平台沙箱执行
*
* 支持:
* - macOS: 应用程序隔离
* - Windows: Hyper-V/WSL 隔离
* - Linux: Docker/容器隔离
*/
import * as path from 'path';
import * as fs from 'fs';
import { spawn, ChildProcess } from 'child_process';
import log from 'electron-log';
import { APP_NAME_IDENTIFIER } from '@shared/constants';
export type Platform = 'darwin' | 'win32' | 'linux';
export interface SandboxConfig {
enabled: boolean;
type: 'none' | 'macos-app-sandbox' | 'docker' | 'wsl' | 'firejail';
image?: string; // Docker 镜像
workspaceDir: string;
}
export interface SandboxStatus {
ready: boolean;
type: string;
containerId?: string;
error?: string;
}
export interface SandboxRuntime {
id: string;
pid: number;
cwd: string;
startedAt: number;
}
// ==================== Platform Detection ====================
export function getPlatform(): Platform {
return process.platform as Platform;
}
export function isMacOS(): boolean {
return process.platform === 'darwin';
}
export function isWindows(): boolean {
return process.platform === 'win32';
}
export function isLinux(): boolean {
return process.platform === 'linux';
}
// ==================== Docker Detection ====================
export async function checkDocker(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('docker', ['--version'], {
stdio: ['ignore', 'pipe', 'ignore'],
});
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
export async function checkWSL(): Promise<boolean> {
if (!isWindows()) return false;
return new Promise((resolve) => {
const proc = spawn('wsl', ['--status'], {
stdio: ['ignore', 'pipe', 'ignore'],
});
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
// ==================== Sandbox Manager ====================
class SandboxManager {
private config: SandboxConfig;
private runtime: SandboxRuntime | null = null;
private process: ChildProcess | null = null;
constructor() {
this.config = {
enabled: false,
type: 'none',
workspaceDir: '',
};
}
/**
* 初始化沙箱配置
*/
async init(config: Partial<SandboxConfig>): Promise<void> {
this.config = {
enabled: false,
type: 'none',
workspaceDir: config.workspaceDir || '',
...config,
};
// 自动检测可用沙箱类型
if (this.config.enabled) {
await this.detectAvailableSandbox();
}
}
/**
* 检测可用沙箱
*/
async detectAvailableSandbox(): Promise<string> {
const platform = getPlatform();
if (platform === 'darwin') {
// macOS: 尝试 Docker
const hasDocker = await checkDocker();
if (hasDocker) {
this.config.type = 'docker';
return 'docker';
}
// 回退到应用沙箱
this.config.type = 'macos-app-sandbox';
return 'macos-app-sandbox';
}
if (platform === 'win32') {
// Windows: 尝试 WSL 或 Docker
const hasWSL = await checkWSL();
const hasDocker = await checkDocker();
if (hasWSL) {
this.config.type = 'wsl';
return 'wsl';
}
if (hasDocker) {
this.config.type = 'docker';
return 'docker';
}
this.config.type = 'none';
return 'none';
}
if (platform === 'linux') {
// Linux: Docker 或 Firejail
const hasDocker = await checkDocker();
if (hasDocker) {
this.config.type = 'docker';
return 'docker';
}
// 尝试 firejail
const hasFirejail = await this.checkCommand('firejail');
if (hasFirejail) {
this.config.type = 'firejail';
return 'firejail';
}
this.config.type = 'none';
return 'none';
}
return 'none';
}
private async checkCommand(cmd: string): Promise<boolean> {
return new Promise((resolve) => {
const checkCmd = process.platform === 'win32' ? 'where' : 'which';
const proc = spawn(checkCmd, [cmd], { stdio: ['ignore', 'pipe', 'ignore'] });
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
/**
* 启动沙箱
*/
async start(image?: string): Promise<{ success: boolean; error?: string }> {
if (!this.config.enabled || this.config.type === 'none') {
return { success: true };
}
const platform = getPlatform();
try {
switch (this.config.type) {
case 'docker':
return await this.startDocker(image || this.config.image || 'alpine');
case 'wsl':
return await this.startWSL();
case 'firejail':
return await this.startFirejail();
case 'macos-app-sandbox':
return { success: true }; // macOS 应用沙箱由系统管理
default:
return { success: true };
}
} catch (error) {
log.error('[Sandbox] Start failed:', error);
return { success: false, error: String(error) };
}
}
/**
* 启动 Docker 沙箱
*/
private async startDocker(image: string): Promise<{ success: boolean; error?: string }> {
const containerName = `${APP_NAME_IDENTIFIER}-${Date.now()}`;
const workspace = this.config.workspaceDir;
return new Promise((resolve) => {
// 启动 Docker 容器
const proc = spawn('docker', [
'run',
'-d',
'--name', containerName,
'-v', `${workspace}:/workspace`,
'-w', '/workspace',
image,
'sleep', 'infinity',
], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stderr = '';
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
proc.on('close', (code) => {
if (code === 0) {
this.runtime = {
id: containerName,
pid: proc.pid || 0,
cwd: workspace,
startedAt: Date.now(),
};
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || 'Docker start failed' });
}
});
});
}
/**
* 启动 WSL 沙箱
*/
private async startWSL(): Promise<{ success: boolean; error?: string }> {
const workspace = this.config.workspaceDir;
return new Promise((resolve) => {
// 在 WSL 中启动
const proc = spawn('wsl', [
'bash', '-c',
`cd "${workspace}" && sleep infinity`
], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
proc.on('close', (code) => {
resolve({ success: code === 0 });
});
// 等待启动
setTimeout(() => {
if (proc.pid) {
this.runtime = {
id: 'wsl',
pid: proc.pid,
cwd: workspace,
startedAt: Date.now(),
};
resolve({ success: true });
}
}, 2000);
});
}
/**
* 启动 Firejail 沙箱
*/
private async startFirejail(): Promise<{ success: boolean; error?: string }> {
// Firejail 需要在运行命令时使用,不需要预先启动
return { success: true };
}
/**
* 在沙箱中执行命令
*/
async execute(
command: string,
args: string[] = [],
options?: { env?: Record<string, string>; cwd?: string }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const platform = getPlatform();
const cwd = options?.cwd || this.config.workspaceDir;
// 如果未启用沙箱,直接执行
if (!this.config.enabled || this.config.type === 'none') {
return this.executeDirect(command, args, options);
}
try {
switch (this.config.type) {
case 'docker':
return await this.executeInDocker(command, args, { cwd, ...options });
case 'wsl':
return await this.executeInWSL(command, args, { cwd, ...options });
case 'firejail':
return await this.executeInFirejail(command, args, { cwd, ...options });
default:
return this.executeDirect(command, args, options);
}
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* 直接执行
*/
private executeDirect(
command: string,
args: string[],
options?: { env?: Record<string, string>; cwd?: string }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd: options?.cwd,
env: { ...process.env, ...options?.env },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({
success: code === 0,
stdout,
stderr,
});
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 在 Docker 中执行
*/
private executeInDocker(
command: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
if (!this.runtime?.id) {
return { success: false, error: 'Sandbox not running' };
}
const dockerArgs = [
'exec',
'-i',
this.runtime.id,
'sh', '-c',
`${command} ${args.join(' ')}`
];
return new Promise((resolve) => {
const proc = spawn('docker', dockerArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({ success: code === 0, stdout, stderr });
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 在 WSL 中执行
*/
private executeInWSL(
command: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const cwd = options?.cwd || this.config.workspaceDir;
const fullCommand = `${command} ${args.join(' ')}`;
return new Promise((resolve) => {
const proc = spawn('wsl', [
'bash', '-c',
`cd "${cwd}" && ${fullCommand}`
], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({ success: code === 0, stdout, stderr });
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 在 Firejail 中执行
*/
private executeInFirejail(
command: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const cwd = options?.cwd || this.config.workspaceDir;
return new Promise((resolve) => {
const proc = spawn('firejail', [
'--noprofile',
`--directory=${cwd}`,
'--',
command,
...args
], {
cwd,
env: { ...process.env, ...options?.env },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({ success: code === 0, stdout, stderr });
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 停止沙箱
*/
async stop(): Promise<void> {
if (!this.runtime) return;
try {
if (this.config.type === 'docker' && this.runtime.id) {
spawn('docker', ['stop', this.runtime.id], {
stdio: 'ignore',
windowsHide: true,
});
spawn('docker', ['rm', '-f', this.runtime.id], {
stdio: 'ignore',
windowsHide: true,
});
}
if (this.process) {
this.process.kill();
}
} catch (error) {
log.error('[Sandbox] Stop error:', error);
}
this.runtime = null;
this.process = null;
}
/**
* 获取状态
*/
getStatus(): SandboxStatus {
return {
ready: !!this.runtime,
type: this.config.type,
containerId: this.runtime?.id,
};
}
/**
* 获取配置
*/
getConfig(): SandboxConfig {
return { ...this.config };
}
/**
* 是否启用沙箱
*/
isEnabled(): boolean {
return this.config.enabled;
}
}
export const sandboxManager = new SandboxManager();
export default sandboxManager;

View File

@@ -0,0 +1 @@
export * from './core/ai';

View File

@@ -0,0 +1 @@
export * from './core/api';

View File

@@ -0,0 +1 @@
export * from './core/auth';

View File

@@ -0,0 +1,86 @@
import Anthropic from '@anthropic-ai/sdk';
export interface AIConfig {
apiKey: string;
model: string;
maxTokens?: number;
}
export interface Message {
role: 'user' | 'assistant';
content: string;
}
export interface ChatOptions {
messages: Message[];
systemPrompt?: string;
model?: string;
maxTokens?: number;
onChunk?: (text: string) => void;
}
class AIService {
private client: Anthropic | null = null;
private config: AIConfig | null = null;
configure(config: AIConfig) {
this.config = config;
this.client = new Anthropic({
apiKey: config.apiKey,
});
}
isConfigured(): boolean {
return this.client !== null;
}
async chat(options: ChatOptions): Promise<string> {
if (!this.client || !this.config) {
throw new Error('AI service not configured');
}
const model = options.model || this.config.model;
const system = options.systemPrompt || 'You are a helpful AI assistant.';
const response = await this.client.messages.create({
model,
max_tokens: options.maxTokens || this.config.maxTokens || 4096,
system,
messages: options.messages.map((m) => ({
role: m.role,
content: m.content,
})),
});
return response.content[0]?.type === 'text' ? response.content[0].text : '';
}
async *streamChat(options: ChatOptions): AsyncGenerator<string> {
if (!this.client || !this.config) {
throw new Error('AI service not configured');
}
const model = options.model || this.config.model;
const system = options.systemPrompt || 'You are a helpful AI assistant.';
const response = await this.client.messages.create({
model,
max_tokens: options.maxTokens || this.config.maxTokens || 4096,
system,
messages: options.messages.map((m) => ({
role: m.role,
content: m.content,
})),
stream: true,
});
// @ts-ignore - streaming response
for await (const chunk of response) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
yield chunk.delta.text;
}
}
}
}
export const aiService = new AIService();

View File

@@ -0,0 +1,191 @@
/**
* 单元测试: api (请求封装)
*
* 覆盖场景:
* - fetch 超时AbortSignal.timeout 触发后抛出可读错误P0-3 修复验证)
* - 正常请求:成功返回 data
* - HTTP 错误:非 2xx 响应正确抛出
* - API 业务错误码:非 0000 code 正确抛出
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ==================== Mocks ====================
// Mock antd message避免 DOM 依赖)
vi.mock('antd', () => ({
message: {
error: vi.fn(),
success: vi.fn(),
loading: vi.fn(),
info: vi.fn(),
},
}));
// Mock @shared/constants
vi.mock('@shared/constants', () => ({
DEFAULT_SERVER_HOST: 'https://default.example.com',
DEFAULT_API_TIMEOUT: 30000,
}));
// ==================== Helpers ====================
/** 构造一个标准成功响应 */
function makeSuccessResponse<T>(data: T): Response {
return new Response(
JSON.stringify({ code: '0000', message: 'ok', success: true, data }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
/** 构造一个业务错误响应HTTP 200 但 code != 0000 */
function makeApiErrorResponse(code: string, msg: string): Response {
return new Response(
JSON.stringify({ code, message: msg, success: false, data: null }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
// 每次需要 fresh module避免 vi.mock 缓存影响)
async function loadApi() {
vi.resetModules();
return import('./api');
}
// ==================== Tests ====================
describe('apiRequest', () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
// ---------- 正常请求 ----------
it('请求成功时应返回 data 字段', async () => {
global.fetch = vi.fn().mockResolvedValue(makeSuccessResponse({ id: 1, name: 'test' }));
const { apiRequest } = await loadApi();
const result = await apiRequest<{ id: number; name: string }>('/test', {
method: 'POST',
showError: false,
});
expect(result).toEqual({ id: 1, name: 'test' });
});
it('应将 AbortSignal.timeout 传入 fetchP0-3 修复验证)', async () => {
global.fetch = vi.fn().mockResolvedValue(makeSuccessResponse({}));
const { apiRequest } = await loadApi();
await apiRequest('/test', { showError: false });
const [, fetchOptions] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
// 验证 signal 字段存在AbortSignal.timeout 已挂载)
expect(fetchOptions.signal).toBeDefined();
expect(fetchOptions.signal).toBeInstanceOf(AbortSignal);
});
// ---------- 超时处理P0-3----------
it('fetch 超时TimeoutError→ 应抛出包含"请求超时"的错误,不暴露原始 AbortErrorP0-3 修复验证)', async () => {
// 模拟 AbortSignal.timeout 触发后 fetch 抛出 TimeoutError
const timeoutError = Object.assign(new Error('The operation timed out.'), {
name: 'TimeoutError',
});
global.fetch = vi.fn().mockRejectedValue(timeoutError);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow(/请求超时/);
});
it('fetch AbortError → 同样转为可读超时错误P0-3 修复验证)', async () => {
const abortError = Object.assign(new Error('The user aborted a request.'), {
name: 'AbortError',
});
global.fetch = vi.fn().mockRejectedValue(abortError);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow(/请求超时/);
});
it('超时时 showError=false 不弹 toast', async () => {
const { message } = await import('antd');
const timeoutError = Object.assign(new Error('timeout'), { name: 'TimeoutError' });
global.fetch = vi.fn().mockRejectedValue(timeoutError);
const { apiRequest } = await loadApi();
await expect(apiRequest('/test', { showError: false })).rejects.toThrow();
expect(message.error).not.toHaveBeenCalled();
});
// ---------- HTTP 错误 ----------
it('HTTP 非 2xx → 应抛出 HTTP 错误', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }),
);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow('HTTP 500');
});
// ---------- 业务错误码 ----------
it('业务错误码非 0000 → 应抛出业务错误', async () => {
global.fetch = vi.fn().mockResolvedValue(
makeApiErrorResponse('4010', '用户未登录,请重新登录'),
);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow('用户未登录,请重新登录');
});
// ---------- registerClient ----------
it('registerClient 应正确透传参数并返回响应', async () => {
const mockData = {
id: 1, scope: 'default', userId: 1, name: 'Bot',
configKey: 'ck-abc', configValue: {}, description: '',
isActive: true, online: true, created: '', modified: '',
};
global.fetch = vi.fn().mockResolvedValue(makeSuccessResponse(mockData));
const { registerClient } = await loadApi();
const result = await registerClient(
{
username: 'user1',
password: 'pass1',
sandboxConfigValue: { agentPort: 4000, vncPort: 0, fileServerPort: 60000 },
},
{ suppressToast: true },
);
expect(result.configKey).toBe('ck-abc');
// 验证请求 body 包含正确参数
const [, fetchOptions] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(fetchOptions.body);
expect(body.username).toBe('user1');
expect(body.password).toBe('pass1');
});
});

View File

@@ -0,0 +1,240 @@
/**
* QimingClaw API 请求封装
* 统一处理请求、响应、错误码
*/
import { message } from "antd";
import { DEFAULT_SERVER_HOST, DEFAULT_API_TIMEOUT } from "@shared/constants";
import { logger } from "../utils/logService";
import { t } from "./i18n";
// 错误码定义
const SUCCESS_CODE = "0000";
/**
* 错误码 → i18n key不在模块加载时调用 t
*
* 说明i18n.ts 会 import apiRequest若此处在顶层执行 t(),会与 i18n 形成循环依赖,
* 此时 t 尚未完成初始化,运行时报 “Cannot access 't' before initialization”。
* 仅在 apiRequest 执行时再 t(key),此时模块图已就绪。
*/
const ERROR_MESSAGE_KEYS: Record<string, string> = {
"0000": "Claw.Api.success",
"4010": "Claw.Api.notLoggedIn",
"4011": "Claw.Api.loginExpired",
"1001": "Claw.Api.clientNotFound",
"9999": "Claw.Api.systemError",
};
/** 按错误码取已翻译的兜底文案(无映射时返回 undefined */
function translatedErrorForCode(code: string): string | undefined {
const key = ERROR_MESSAGE_KEYS[code];
return key ? t(key) : undefined;
}
// 响应类型定义(内部使用)
interface ApiResponse<T = any> {
code: string;
displayCode?: string;
message: string;
success: boolean;
data: T;
tid?: string;
}
// 请求配置(内部使用)
interface RequestConfig {
baseUrl?: string;
timeout?: number;
headers?: Record<string, string>;
cache?: RequestCache;
}
// 默认配置
const DEFAULT_CONFIG: RequestConfig = {
baseUrl: DEFAULT_SERVER_HOST,
timeout: DEFAULT_API_TIMEOUT,
};
/**
* 统一的 API 请求函数
*/
export async function apiRequest<T>(
url: string,
options: {
method?: "GET" | "POST" | "PUT" | "DELETE";
data?: any;
params?: Record<string, any>;
headers?: Record<string, string>;
showError?: boolean;
baseUrl?: string;
cache?: RequestCache;
} = {},
): Promise<T> {
const config = { ...DEFAULT_CONFIG, ...options };
const fullUrl = `${config.baseUrl}${url}`;
// 使用 AbortSignal.timeout 实现请求超时,避免网络挂起时永久阻塞。
// 运行时要求Electron 40+Chromium 120+)支持 AbortSignal.timeout若需兼容更旧版本需 polyfill如 setTimeout + AbortController
const timeoutMs = config.timeout ?? DEFAULT_API_TIMEOUT;
const fetchOptions: RequestInit = {
method: options.method || "POST",
headers: {
"Content-Type": "application/json",
...config.headers,
},
signal: AbortSignal.timeout(timeoutMs),
...(config.cache ? { cache: config.cache } : {}),
};
if (options.data) {
fetchOptions.body = JSON.stringify(options.data);
}
let finalUrl = fullUrl;
if (options.params) {
const searchParams = new URLSearchParams();
Object.entries(options.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
if (queryString) {
finalUrl = `${fullUrl}?${queryString}`;
}
}
try {
const response = await fetch(finalUrl, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result: ApiResponse<T> = await response.json();
// 统一错误处理
if (result.code !== SUCCESS_CODE) {
const errorMsg =
result.message ||
translatedErrorForCode(result.code) ||
`请求失败 (错误码: ${result.code})`;
logger.error("API Error", "API", {
code: result.code,
message: result.message,
});
if (options.showError !== false) {
message.error(errorMsg);
}
throw new Error(errorMsg);
}
return result.data;
} catch (error: any) {
// AbortSignal.timeout 超时后抛出 TimeoutErrorname === 'TimeoutError'
// 或 AbortError某些环境统一转为可读错误
if (error.name === "TimeoutError" || error.name === "AbortError") {
const timeoutMsg = t("Claw.Api.timeout", timeoutMs);
logger.error("Request Timeout", "API", { url: finalUrl });
if (options.showError !== false) {
message.error(timeoutMsg);
}
throw new Error(timeoutMsg);
}
logger.error("Request Error", "API", error);
// 检测是否是重定向到登录页面的情况(后端返回 HTML
const isLoginRedirect =
error.message?.includes("/login") || error.message?.includes("redirect");
// 生成用户友好的错误信息
let userMessage: string = "";
if (isLoginRedirect) {
userMessage = t("Claw.Errors.loginRedirect");
} else if (options.showError !== false && error.message) {
userMessage = error.message;
}
if (options.showError !== false && userMessage) {
message.error(userMessage);
}
throw error;
}
}
// ========== 客户端注册接口 ==========
/**
* 沙盒配置值
*/
export interface SandboxValue {
hostWithScheme?: string;
agentPort: number;
vncPort: number;
fileServerPort: number;
guiMcpPort: number;
adminServerPort: number;
apiKey?: string;
maxUsers?: number;
}
/**
* 客户端注册请求参数
*/
export interface ClientRegisterParams {
username: string;
password: string;
savedKey?: string;
deviceId?: string;
sandboxConfigValue: SandboxValue;
}
/**
* 客户端注册响应数据 (SandboxConfigDto)
*/
export interface ClientRegisterResponse {
id: number;
scope: string;
userId: number;
name: string;
configKey: string;
configValue: SandboxValue;
description: string;
isActive: boolean;
online: boolean;
created: string;
modified: string;
/** 服务器地址(客户端连接用) */
serverHost?: string;
/** 服务器端口(客户端连接用) */
serverPort?: number;
/** 登录态 token用于 webview cookie 同步 */
token?: string;
}
/**
* 注册客户端
*
* @param params 注册参数
* @returns 注册响应数据
*/
export async function registerClient(
params: ClientRegisterParams,
options?: {
baseUrl?: string;
suppressToast?: boolean;
},
): Promise<ClientRegisterResponse> {
return apiRequest<ClientRegisterResponse>("/api/sandbox/config/reg", {
method: "POST",
data: params,
showError: !options?.suppressToast,
baseUrl: options?.baseUrl,
});
}

View File

@@ -0,0 +1,370 @@
/**
* 单元测试: auth (快捷登录 / savedKey 认证)
*
* 覆盖场景:
* - savedKey 认证下 username/password 为空的登录判断
* - syncConfigToServer / reRegisterClient 的 guard 条件
* - logout 后用账号密码重新登录
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ==================== Mocks ====================
// In-memory settings store (模拟 SQLite)
let store: Record<string, unknown> = {};
const mockSettingsGet = vi.fn(async (key: string) => store[key] ?? null);
const mockSettingsSet = vi.fn(async (key: string, value: unknown) => {
if (value === null || value === undefined) {
delete store[key];
} else {
store[key] = value;
}
});
// Mock window.electronAPI
vi.stubGlobal("window", {
electronAPI: {
app: {
getDeviceId: vi.fn(async () => "mock-device-id"),
},
settings: {
get: mockSettingsGet,
set: mockSettingsSet,
},
},
});
// Mock antd message
vi.mock("antd", () => ({
message: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
// Mock registerClient API
const mockRegisterClient = vi.fn();
vi.mock("./api", () => ({
registerClient: (...args: unknown[]) => mockRegisterClient(...args),
}));
// ==================== Helpers ====================
const DOMAIN = "https://testagent.xspaceagi.com";
const SAVED_KEY = "test-saved-key-abc123";
const CONFIG_KEY_FROM_SERVER = "server-returned-config-key";
function makeRegisterResponse(overrides?: Partial<Record<string, unknown>>) {
return {
id: 1,
scope: "default",
userId: 1,
name: "TestUser",
configKey: CONFIG_KEY_FROM_SERVER,
configValue: {},
description: "",
isActive: true,
online: true,
created: "",
modified: "",
serverHost: "proxy.example.com",
serverPort: 4900,
...overrides,
};
}
// ==================== Tests ====================
describe("auth - savedKey 认证 (快捷登录)", () => {
beforeEach(() => {
vi.clearAllMocks();
store = {};
mockRegisterClient.mockResolvedValue(makeRegisterResponse());
});
// 每次需要 fresh module避免内部状态缓存
async function loadAuth() {
vi.resetModules();
return import("./auth");
}
// ---------- isLoggedIn ----------
describe("isLoggedIn", () => {
it("应以 configKey 为准,不依赖 username", async () => {
store["auth.config_key"] = "some-config-key";
// username 不存在
const { isLoggedIn } = await loadAuth();
expect(await isLoggedIn()).toBe(true);
});
it("configKey 为空时应返回 false", async () => {
store["auth.username"] = "user1";
// configKey 不存在
const { isLoggedIn } = await loadAuth();
expect(await isLoggedIn()).toBe(false);
});
it("username 为空字符串 + configKey 存在 → 已登录", async () => {
store["auth.username"] = "";
store["auth.config_key"] = "key";
const { isLoggedIn } = await loadAuth();
expect(await isLoggedIn()).toBe(true);
});
});
// ---------- getCurrentAuth ----------
describe("getCurrentAuth", () => {
it("应以 configKey 判断 isLoggedIn与 username 无关", async () => {
store["auth.config_key"] = "key";
store["auth.user_info"] = { username: "", displayName: "Bot" };
const { getCurrentAuth } = await loadAuth();
const auth = await getCurrentAuth();
expect(auth.isLoggedIn).toBe(true);
expect(auth.username).toBeNull(); // username key 未设置
});
});
// ---------- syncConfigToServer ----------
describe("syncConfigToServer", () => {
it("username 和 password 均为空字符串 + 无 savedKey → 应拒绝P1-5 修复验证)", async () => {
// 修复前:空字符串不等于 null会绕过 guard 向后端发空凭证请求
// 修复后:!'' === true正确拦截
store["auth.username"] = "";
store["auth.password"] = "";
// 不设置 savedKey
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("username/password 为空字符串 + 有 savedKey → 应正常同步", async () => {
store["auth.username"] = "";
store["auth.password"] = "";
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).not.toBeNull();
expect(result!.configKey).toBe(CONFIG_KEY_FROM_SERVER);
expect(mockRegisterClient).toHaveBeenCalledTimes(1);
// 验证请求参数
const [params] = mockRegisterClient.mock.calls[0];
expect(params.username).toBe("");
expect(params.password).toBe("");
expect(params.savedKey).toBe(SAVED_KEY);
});
it("username/password 为 null + 有 savedKey → 应正常同步", async () => {
// username/password 未设置null
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).not.toBeNull();
const [params] = mockRegisterClient.mock.calls[0];
expect(params.username).toBe("");
expect(params.password).toBe("");
expect(params.savedKey).toBe(SAVED_KEY);
});
it("无 username/password/savedKey → 应拒绝", async () => {
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("有 username 但无 savedKey → 应拒绝(密码不再持久化,必须依赖 savedKey", async () => {
store["auth.username"] = "user1";
// 密码不再持久化保存,不设置 savedKey 时无法同步
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
});
// ---------- reRegisterClient ----------
describe("reRegisterClient", () => {
it("username/password 为 null + 有 savedKey → 应正常重注册", async () => {
store["auth.saved_key"] = SAVED_KEY;
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
expect(result).not.toBeNull();
expect(mockRegisterClient).toHaveBeenCalledTimes(1);
});
it("无任何凭证 → 应拒绝", async () => {
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("有 domain+username → 应使用域名级 savedKey而非全局 keyP0-1 修复验证)", async () => {
// 域名级 key 与全局 key 故意不同,验证使用的是域名级
store["auth.username"] = "user1";
store["lanproxy.server_host"] = DOMAIN; // AUTH_KEYS.LANPROXY_SERVER_HOST
store["auth.saved_keys.testagent.xspaceagi.com_user1"] =
"domain-specific-key";
store["auth.saved_key"] = "global-key-different";
const { reRegisterClient } = await loadAuth();
await reRegisterClient();
const [params] = mockRegisterClient.mock.calls[0];
// 应使用域名级 savedKey不应使用全局 key
expect(params.savedKey).toBe("domain-specific-key");
});
it("多账号切换:当前用户无专属 savedKey 时,应拒绝(不应使用全局 key 中其他账号的凭证)", async () => {
// 模拟用户A之前登录全局 key 被覆盖为 A 的凭证
store["auth.saved_keys.testagent.xspaceagi.com_userA"] = "key-for-userA";
store["auth.saved_key"] = "key-for-userA"; // 全局 key 指向 A
// 当前切换为用户B没有域名级专属 key
store["auth.username"] = "userB";
store["lanproxy.server_host"] = DOMAIN; // AUTH_KEYS.LANPROXY_SERVER_HOST
// 不设置 auth.saved_keys.*.userB
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
// 用户B无专属 key应拒绝重注册不应使用用户A的 key
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("无 domain 信息时 → 应回退到全局 savedKey", async () => {
// 没有 domain 配置,只有全局 key
store["auth.username"] = "user1";
// 不设置 lanproxy_server_host 和 step1_config
store["auth.saved_key"] = SAVED_KEY;
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
expect(result).not.toBeNull();
const [params] = mockRegisterClient.mock.calls[0];
// 无 domain 时 fallback 到全局 key
expect(params.savedKey).toBe(SAVED_KEY);
});
});
// ---------- loginAndRegister ----------
describe("loginAndRegister", () => {
it("空 username + 空 password + savedKey → 应正常登录", async () => {
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
const { loginAndRegister } = await loadAuth();
const result = await loginAndRegister("", "", {
suppressToast: true,
domain: DOMAIN,
});
expect(result.configKey).toBe(CONFIG_KEY_FROM_SERVER);
// 验证登录后存储了 configKey
expect(store["auth.config_key"]).toBe(CONFIG_KEY_FROM_SERVER);
// savedKey 更新为服务端返回的 configKey
expect(store["auth.saved_key"]).toBe(CONFIG_KEY_FROM_SERVER);
});
it("正常 username + password → 应正常登录(密码不持久化)", async () => {
store["step1_config"] = { serverHost: DOMAIN };
const { loginAndRegister } = await loadAuth();
const result = await loginAndRegister("zhangsan", "abc123", {
suppressToast: true,
domain: DOMAIN,
});
expect(result.configKey).toBe(CONFIG_KEY_FROM_SERVER);
expect(store["auth.username"]).toBe("zhangsan");
// 密码不再持久化保存
expect(store["auth.password"]).toBeUndefined();
// savedKey 应保存
expect(store["auth.saved_key"]).toBe(CONFIG_KEY_FROM_SERVER);
});
});
// ---------- 完整场景: 快捷登录 → 退出 → 重新登录 ----------
describe("完整流程: 快捷登录 → logout → 账号密码登录", () => {
it("退出后用账号密码重新登录应成功", async () => {
const auth = await loadAuth();
// 1. 快捷登录(模拟 QuickInit: 空 username + savedKey
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
await auth.loginAndRegister("", "", {
suppressToast: true,
domain: DOMAIN,
});
expect(await auth.isLoggedIn()).toBe(true);
// 2. 退出登录
await auth.logout();
expect(await auth.isLoggedIn()).toBe(false);
// savedKey 应保留
expect(store["auth.saved_key"]).toBe(CONFIG_KEY_FROM_SERVER);
// configKey 应清除
expect(store["auth.config_key"]).toBeUndefined();
// 3. 用新账号密码登录
const newConfigKey = "new-config-key-for-zhangsan";
mockRegisterClient.mockResolvedValueOnce(
makeRegisterResponse({ configKey: newConfigKey }),
);
await auth.loginAndRegister("zhangsan", "dynamic-code", {
suppressToast: true,
domain: DOMAIN,
});
expect(await auth.isLoggedIn()).toBe(true);
expect(store["auth.username"]).toBe("zhangsan");
expect(store["auth.config_key"]).toBe(newConfigKey);
// 域名级 savedKey 应保存
expect(store["auth.saved_keys.testagent.xspaceagi.com_zhangsan"]).toBe(
newConfigKey,
);
// 4. syncConfigToServer 应能正常工作
mockRegisterClient.mockResolvedValueOnce(
makeRegisterResponse({ configKey: newConfigKey }),
);
const syncResult = await auth.syncConfigToServer({ suppressToast: true });
expect(syncResult).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,674 @@
/**
* 认证服务 (Electron 版)
* 管理用户登录状态、ConfigKey/SavedKey 存储
* 使用 window.electronAPI.settings 替代 Tauri Store
*/
import { message } from "antd";
import {
registerClient,
ClientRegisterParams,
ClientRegisterResponse,
SandboxValue,
} from "./api";
import {
AUTH_KEYS,
LOCAL_HOST_URL,
DEFAULT_AGENT_RUNNER_PORT,
DEFAULT_FILE_SERVER_PORT,
DEFAULT_GUI_MCP_PORT,
DEFAULT_ADMIN_SERVER_PORT,
} from "@shared/constants";
import { syncSessionCookie } from "../utils/sessionUrl";
import { logger } from "../utils/logService";
import {
getDomainTokenKey,
normalizeDomainForTokenKey,
} from "@shared/utils/domain";
import { t } from "./i18n";
// ========== 类型定义 ===
export interface AuthUserInfo {
id?: number;
username: string;
displayName?: string;
avatar?: string;
email?: string;
phone?: string;
currentDomain?: string;
}
// ========== 存储辅助函数 ===
async function settingsGet<T>(key: string): Promise<T | null> {
try {
const value = await window.electronAPI?.settings.get(key);
return (value as T) ?? null;
} catch {
return null;
}
}
async function settingsSet(key: string, value: unknown): Promise<void> {
await window.electronAPI?.settings.set(key, value);
}
// 域名标准化使用共享函数 normalizeDomainForTokenKey
// 保留别名以兼容现有调用
const normalizeDomain = normalizeDomainForTokenKey;
// ========== 存储操作 ===
async function getUsername(): Promise<string | null> {
return settingsGet<string>(AUTH_KEYS.USERNAME);
}
async function setUsername(value: string): Promise<void> {
await settingsSet(AUTH_KEYS.USERNAME, value);
}
async function getPassword(): Promise<string | null> {
return settingsGet<string>(AUTH_KEYS.PASSWORD);
}
async function setPassword(value: string): Promise<void> {
await settingsSet(AUTH_KEYS.PASSWORD, value);
}
async function getConfigKey(): Promise<string | null> {
return settingsGet<string>(AUTH_KEYS.CONFIG_KEY);
}
async function setConfigKey(value: string): Promise<void> {
await settingsSet(AUTH_KEYS.CONFIG_KEY, value);
}
async function getSavedKey(
domain?: string,
username?: string,
): Promise<string | null> {
if (domain && username) {
const key = `${AUTH_KEYS.SAVED_KEYS_PREFIX}${normalizeDomain(domain)}_${username}`;
return settingsGet<string>(key);
}
return settingsGet<string>(AUTH_KEYS.SAVED_KEY);
}
async function setSavedKey(
value: string,
domain?: string,
username?: string,
): Promise<void> {
if (domain && username) {
const key = `${AUTH_KEYS.SAVED_KEYS_PREFIX}${normalizeDomain(domain)}_${username}`;
await settingsSet(key, value);
}
await settingsSet(AUTH_KEYS.SAVED_KEY, value);
}
async function getUserInfo(): Promise<AuthUserInfo | null> {
return settingsGet<AuthUserInfo>(AUTH_KEYS.USER_INFO);
}
async function setUserInfo(value: AuthUserInfo): Promise<void> {
await settingsSet(AUTH_KEYS.USER_INFO, value);
}
async function setOnlineStatus(value: boolean): Promise<void> {
await settingsSet(AUTH_KEYS.ONLINE_STATUS, value);
}
async function saveServerConfig(
serverHost: string,
serverPort: number,
): Promise<void> {
await settingsSet(AUTH_KEYS.LANPROXY_SERVER_HOST, serverHost);
await settingsSet(AUTH_KEYS.LANPROXY_SERVER_PORT, serverPort);
// 同步到 lanproxy_configLanproxySettings 可编辑的配置)
// clientKey 不存入 lanproxy_config始终从 auth.saved_key 读取(参考 Tauri 客户端)
const existing =
await settingsGet<Record<string, unknown>>("lanproxy_config");
await settingsSet("lanproxy_config", {
...existing,
serverIp: serverHost.replace(/^https?:\/\//, ""),
serverPort,
enabled: true,
});
logger.info("Lanproxy server config saved", "Auth", {
serverHost,
serverPort,
});
}
async function clearAuthInfo(): Promise<void> {
await settingsSet(AUTH_KEYS.USERNAME, null);
await settingsSet(AUTH_KEYS.PASSWORD, null);
await settingsSet(AUTH_KEYS.CONFIG_KEY, null);
await settingsSet(AUTH_KEYS.USER_INFO, null);
await settingsSet(AUTH_KEYS.ONLINE_STATUS, null);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
// 不清除 savedKey跨登录会话持久化
}
// ========== Token 缓存辅助函数 ===
/**
* 缓存登录 token 到 one-shot 和域名级别存储
* 尝试立即同步到 webview cookie成功后清除 one-shot token
*/
async function cacheAndSyncToken(
domain: string,
token: string,
logTag: string = "Auth",
): Promise<void> {
// 写入 one-shot token给后续打开 webview 用)
await settingsSet(AUTH_KEYS.AUTH_TOKEN, token);
// 写入域名级别缓存(兜底)
const domainTokenKey = getDomainTokenKey(domain);
await settingsSet(domainTokenKey, token);
logger.info("Login token cache written", logTag, { domain, domainTokenKey });
// 尝试立即同步到 webview cookie
try {
await syncSessionCookie(domain, token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", logTag);
} catch (e) {
logger.warn("Token sync failed, keeping local cache", logTag, e);
}
}
// ========== 获取本地沙箱配置 ===
async function getLocalSandboxValue(): Promise<SandboxValue> {
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
agentPort?: number;
fileServerPort?: number;
guiMcpPort?: number;
adminServerPort?: number;
} | null;
return {
hostWithScheme: LOCAL_HOST_URL,
agentPort: step1Config?.agentPort ?? DEFAULT_AGENT_RUNNER_PORT,
vncPort: 0, // vncPort 未启用
fileServerPort: step1Config?.fileServerPort ?? DEFAULT_FILE_SERVER_PORT,
guiMcpPort: step1Config?.guiMcpPort ?? DEFAULT_GUI_MCP_PORT,
// Admin Server 已合并到 Computer Server端口与 agentPort 相同
adminServerPort: step1Config?.agentPort ?? DEFAULT_AGENT_RUNNER_PORT,
apiKey: "",
maxUsers: 1,
};
}
// ========== 错误处理 ===
/**
* 获取友好的错误信息
*/
export function getAuthErrorMessage(error: any): string {
if (error?.message) {
return error.message;
}
if (error?.data?.message) {
return error.data.message;
}
const errorCodeMessages: Record<string, string> = {
"1001": t("Claw.Auth.error.userNotFound"),
"1002": t("Claw.Auth.error.wrongPassword"),
"1003": t("Claw.Auth.error.accountDisabled"),
"2001": t("Claw.Auth.error.clientNotFound"),
"2002": t("Claw.Auth.error.clientDisabled"),
"2003": t("Claw.Auth.error.configNotFound"),
"4010": t("Claw.Auth.error.loginExpired"),
"4011": t("Claw.Auth.error.loginExpired"),
"9999": t("Claw.Auth.error.systemError"),
};
if (error?.data?.code && errorCodeMessages[error.data.code]) {
return errorCodeMessages[error.data.code];
}
if (error?.status === 403) return t("Claw.Auth.error.forbidden");
if (error?.status === 404) return t("Claw.Auth.error.notFound");
if (error?.status === 500) return t("Claw.Auth.error.serverError");
return t("Claw.Auth.error.loginFailed");
}
// ========== 域名标准化 ===
export function normalizeServerHost(input: string): string {
let value = input.trim();
if (!value) return value;
value = value.replace(/\/+$/, "");
if (/^https?:\/\//i.test(value)) return value;
return `https://${value}`;
}
// ========== 核心认证函数 ===
/**
* 登录并注册客户端
*/
export async function loginAndRegister(
username: string,
password: string,
options?: { suppressToast?: boolean; domain?: string },
): Promise<ClientRegisterResponse> {
const suppressToast = options?.suppressToast === true;
// 获取并规范化域名
// 优先级:用户显式传入 > lanproxy.server_host > step1_config.serverHost
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
serverHost?: string;
} | null;
let rawDomain = options?.domain || "";
if (!rawDomain) {
rawDomain =
(await settingsGet<string>(AUTH_KEYS.LANPROXY_SERVER_HOST)) || "";
}
if (!rawDomain) {
rawDomain = step1Config?.serverHost || "";
}
const domain = normalizeServerHost(rawDomain);
// 域名变更时写回设置
if (domain && step1Config && domain !== step1Config.serverHost) {
await window.electronAPI?.settings.set("step1_config", {
...step1Config,
serverHost: domain,
});
}
// 获取保存的 savedKey
const savedKey = await getSavedKey(domain, username);
// 构建注册参数
const deviceId = await window.electronAPI?.app.getDeviceId();
const params: ClientRegisterParams = {
username,
password,
savedKey: savedKey || undefined,
deviceId: deviceId || undefined,
sandboxConfigValue: await getLocalSandboxValue(),
};
const loadingKey = "loginLoading";
if (!suppressToast) {
message.loading({
content: t("Claw.Auth.loggingIn"),
key: loadingKey,
duration: 0,
});
}
try {
const response = await registerClient(params, {
baseUrl: domain,
suppressToast: true,
});
// 保存认证信息(不保存密码,后续认证使用 savedKey
await setUsername(username);
// 密码不持久化保存savedKeyconfigKey用于后续自动认证
await setConfigKey(response.configKey);
await setSavedKey(response.configKey, domain, username);
await setUserInfo({
id: response.id,
username,
displayName: response.name,
currentDomain: domain,
});
await setOnlineStatus(response.online);
// 持久化 token用于 webview cookie 同步)
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
if (response.token) {
await settingsSet(AUTH_KEYS.AUTH_TOKEN, response.token);
const domainTokenKey = domain ? getDomainTokenKey(domain) : null;
if (domainTokenKey) {
await settingsSet(domainTokenKey, response.token);
}
logger.info("Login token cache written", "Auth", {
domain,
domainTokenKey,
});
try {
await syncSessionCookie(domain, response.token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", "Auth");
} catch (e) {
logger.warn("Token sync failed, keeping local cache", "Auth", e);
}
} else {
logger.warn(
"reg did not return token, cannot sync webview login state",
"Auth",
{
domain,
},
);
}
// 保存 lanproxy 服务器配置
logger.info("API returned lanproxy config", "Auth", {
serverHost: response.serverHost,
serverPort: response.serverPort,
});
if (response.serverHost && response.serverPort) {
await saveServerConfig(response.serverHost, response.serverPort);
} else {
logger.warn(
"API did not return serverHost/serverPort, lanproxy config not updated",
"Auth",
);
}
// Electron 版:不自动重启服务,登录完成后由 onComplete 触发主界面初始化
if (!suppressToast) {
message.success({
content: t("Claw.Setup.login.success"),
key: loadingKey,
});
}
// 不记录 configKey 全文,避免敏感信息写入控制台/日志
logger.info("Login successful", "Auth", {
configKeySet: !!response.configKey,
name: response.name,
online: response.online,
serverHost: response.serverHost,
serverPort: response.serverPort,
isNewUser: !savedKey,
});
return response;
} catch (error: any) {
const errorMessage = getAuthErrorMessage(error);
// 仅记录安全信息,避免将含 password/request 的 error 对象写入控制台
logger.error("Login failed", "Auth", errorMessage);
if (!suppressToast) {
message.error({ content: errorMessage, key: loadingKey });
}
throw error;
}
}
/**
* 检查是否已登录
* savedKey 认证场景下 username/password 可为空,以 configKey 为准
*/
export async function isLoggedIn(): Promise<boolean> {
const configKey = await getConfigKey();
return !!configKey;
}
/**
* 获取当前登录信息
*/
export async function getCurrentAuth(): Promise<{
username: string | null;
configKey: string | null;
userInfo: AuthUserInfo | null;
isLoggedIn: boolean;
}> {
const username = await getUsername();
const configKey = await getConfigKey();
const userInfo = await getUserInfo();
const isLogged = !!configKey;
return {
username,
configKey,
userInfo,
isLoggedIn: isLogged,
};
}
/**
* 重新注册客户端(使用已保存的 savedKey
*
* 修复:使用 domain + username 级别的 savedKey避免多账号切换时读取到错误账号的凭证
* 注意:密码不持久化,仅依赖 savedKey 进行认证
*/
export async function reRegisterClient(): Promise<ClientRegisterResponse | null> {
const username = await getUsername();
// 读取 domain优先级step1_config.serverHost > lanproxy.server_host
// 因为 savedKey 是用 step1_config.serverHost 保存的
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
serverHost?: string;
} | null;
const lanproxyHost = await settingsGet<string>(
AUTH_KEYS.LANPROXY_SERVER_HOST,
);
const rawDomain = step1Config?.serverHost || lanproxyHost || "";
const domain = normalizeServerHost(rawDomain);
// 按 domain + username 取对应账号的 savedKey而非读全局 key避免多账号混淆
const savedKey =
domain && username
? await getSavedKey(domain, username)
: await getSavedKey();
// 必须有 savedKey 才能重新注册(密码不持久化)
if (!savedKey) {
logger.warn("No savedKey, cannot re-register, please login again", "Auth");
return null;
}
try {
logger.info("Re-registering client (using savedKey)...", "Auth");
const deviceId = await window.electronAPI?.app.getDeviceId();
const params: ClientRegisterParams = {
username: username || "",
password: "", // 密码不持久化,使用 savedKey 认证
savedKey,
deviceId: deviceId || undefined,
sandboxConfigValue: await getLocalSandboxValue(),
};
const response = await registerClient(params, {
baseUrl: domain || undefined,
suppressToast: true,
});
// 更新 savedKey服务端可能返回新的
await setConfigKey(response.configKey);
await setSavedKey(response.configKey, domain, username || undefined);
await setOnlineStatus(response.online);
// 持久化 token用于 webview cookie 同步)
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
if (response.token) {
await settingsSet(AUTH_KEYS.AUTH_TOKEN, response.token);
const domainTokenKey = domain ? getDomainTokenKey(domain) : null;
if (domainTokenKey) {
await settingsSet(domainTokenKey, response.token);
}
logger.info("Login token cache written", "Auth", {
domain,
domainTokenKey,
});
try {
await syncSessionCookie(domain, response.token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", "Auth");
} catch (e) {
logger.warn("Token sync failed, keeping local cache", "Auth", e);
}
} else {
logger.warn(
"reg did not return token, cannot sync webview login state",
"Auth",
{
domain,
},
);
}
logger.info("Re-registration successful", "Auth");
return response;
} catch (error) {
logger.error("Re-registration failed", "Auth", error);
return null;
}
}
/**
* 退出登录
*/
export async function logout(): Promise<void> {
await clearAuthInfo();
message.info(t("Claw.Auth.loggedOut"));
}
/**
* 同步本地配置到后端(调用 reg 接口)。
* reg 返回内容可能会变化(如 serverHost、serverPort 等),本函数会将本次返回的最新值写入配置并返回,调用方应在 reg 成功后再启动服务,以使用最新配置。
* 注意:密码不持久化,仅依赖 savedKey 进行认证
*/
export async function syncConfigToServer(options?: {
suppressToast?: boolean;
}): Promise<ClientRegisterResponse | null> {
const suppressToast = options?.suppressToast === true;
const username = await getUsername();
// 读取 domain优先级step1_config.serverHost > lanproxy.server_host。
// 说明:
// 1) step1_config.serverHost 表示"用户访问业务系统的域名"(登录页输入的域名);
// 2) lanproxy.server_host 表示"代理链路连接地址"reg 返回的 serverHost
// 3) 这里仍保留旧优先级用于请求 reg 与读取 savedKey避免影响既有登录/同步流程。
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
serverHost?: string;
} | null;
const lanproxyHost = await settingsGet<string>(
AUTH_KEYS.LANPROXY_SERVER_HOST,
);
const rawDomain = step1Config?.serverHost || lanproxyHost || "";
const domain = normalizeServerHost(rawDomain);
// 使用持久化的 savedKey参考 Tauri 客户端:退出登录不清除,跨会话持久化)
const savedKey = await getSavedKey(domain, username || undefined);
// 必须有 savedKey 才能同步(密码不持久化)
if (!savedKey) {
logger.warn(
"No savedKey, cannot sync config, please login again",
"SyncConfig",
);
return null;
}
const deviceId = await window.electronAPI?.app.getDeviceId();
const params: ClientRegisterParams = {
username: username || "",
password: "", // 密码不持久化,使用 savedKey 认证
savedKey,
deviceId: deviceId || undefined,
sandboxConfigValue: await getLocalSandboxValue(),
};
const loadingKey = "syncConfigLoading";
if (!suppressToast) {
message.loading({
content: t("Claw.Auth.syncingConfig"),
key: loadingKey,
duration: 0,
});
}
try {
const response = await registerClient(params, {
baseUrl: domain,
suppressToast: true,
});
await setConfigKey(response.configKey);
await setSavedKey(response.configKey, domain, username || undefined);
await setOnlineStatus(response.online);
// 持久化 token用于 webview cookie 同步)
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
if (response.token) {
await settingsSet(AUTH_KEYS.AUTH_TOKEN, response.token);
const domainTokenKey = domain ? getDomainTokenKey(domain) : null;
if (domain) {
await settingsSet(domainTokenKey!, response.token);
}
logger.info("Login token cache written", "SyncConfig", {
domain,
domainTokenKey,
});
try {
await syncSessionCookie(domain, response.token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", "SyncConfig");
} catch (e) {
logger.warn("Token sync failed, keeping local cache", "SyncConfig", e);
}
} else {
logger.warn(
"reg did not return token, cannot sync webview login state",
"SyncConfig",
{
domain,
},
);
}
// 使用本次 reg 返回的最新 serverHost/serverPort 覆盖本地"代理配置"。
// 注意serverHost 是 lanproxy 链路地址,不应回写为 UI 展示/跳转使用的业务域名。
if (response.serverHost && response.serverPort) {
await saveServerConfig(response.serverHost, response.serverPort);
}
const currentUserInfo = await getUserInfo();
// 关键修复:
// - currentDomain 只代表"业务域名"(用于 UI 展示、会话跳转、后续登录目标);
// - reg 返回的 serverHost 仅用于代理配置,不参与 currentDomain 的决定;
// - 当本次同步拿不到明确业务域名domain 为空)时,保留已有 currentDomain避免被代理地址"间接覆盖"。
const preservedCurrentDomain =
domain || currentUserInfo?.currentDomain || undefined;
await setUserInfo({
...currentUserInfo,
id: response.id,
username: username || "",
displayName: response.name,
currentDomain: preservedCurrentDomain,
} as AuthUserInfo);
if (!suppressToast) {
message.success({
content: t("Claw.Auth.configSyncedSuccess"),
key: loadingKey,
});
}
logger.info("Config sync successful", "SyncConfig", {
configKey: response.configKey,
online: response.online,
});
return response;
} catch (error: any) {
const errorMessage = getAuthErrorMessage(error);
logger.error("Config sync failed", "SyncConfig", error);
if (!suppressToast) {
message.error({ content: errorMessage, key: loadingKey });
}
return null;
}
}

View File

@@ -0,0 +1,533 @@
/**
* 渲染进程 i18n 服务
* 后端接口驱动,自动根据系统语言选择翻译
*
* Key 规范:{Client}.{Scope}.{Domain}.{key}
* Client: Claw (Electron 客户端)
*
* 语言文件位于 @shared/locales/
*/
import { apiRequest } from "./api";
// ========== 类型定义 ==========
export type SystemLangMap = Record<string, string>;
export interface I18nLangDto {
id: number;
name: string;
lang: string;
status: number;
isDefault: number;
sort: number;
modified: string;
created: string;
}
// ========== 常量 ==========
const DEFAULT_I18N_LANG = "en-us";
const I18N_STORAGE_KEYS = {
ACTIVE_LANG: "i18n.active_lang",
LANG_MAP_CACHE: "i18n.lang_map_cache",
LANG_MAP_CACHE_AT: "i18n.lang_map_cache_at",
LANG_MAP_CACHE_LANG: "i18n.lang_map_cache_lang",
LANG_FORCE_REFRESH_ON_INIT: "i18n.lang_force_refresh_on_init",
} as const;
const I18N_MAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时
// ========== 导入语言文件Vite 支持 JSON import ==========
import enUS from "@shared/locales/en-US.json";
import zhCN from "@shared/locales/zh-CN.json";
import zhTW from "@shared/locales/zh-TW.json";
import zhHK from "@shared/locales/zh-HK.json";
const LOCALE_MAPS: Record<string, SystemLangMap> = {
en: enUS as SystemLangMap,
"en-us": enUS as SystemLangMap,
zh: zhCN as SystemLangMap,
"zh-cn": zhCN as SystemLangMap,
"zh-tw": zhTW as SystemLangMap,
"zh-hk": zhHK as SystemLangMap,
};
const isLocaleSupported = (lang: string): boolean => {
const normalized = lang.toLowerCase();
return normalized in LOCALE_MAPS;
};
const getLocaleMap = (lang: string): SystemLangMap => {
const normalized = lang.toLowerCase();
// 精确匹配
if (LOCALE_MAPS[normalized]) {
return LOCALE_MAPS[normalized];
}
// 前缀匹配
for (const [key, map] of Object.entries(LOCALE_MAPS)) {
if (normalized.startsWith(key)) {
return map;
}
}
// 中文泛匹配
if (normalized.startsWith("zh")) {
return zhCN as SystemLangMap;
}
return enUS as SystemLangMap;
};
const getLocalBaseMap = (lang: string): SystemLangMap => {
const normalized = normalizeLang(lang);
return getLocaleMap(normalized);
};
// ========== 状态 ==========
let currentLang = DEFAULT_I18N_LANG;
let langMap: SystemLangMap = { ...(enUS as SystemLangMap) };
let isCurrentLangSupported_ = true;
let zhBaseMap: SystemLangMap = { ...(zhCN as SystemLangMap) };
let zhValueToKeyMap: Record<string, string> = {};
let initPromise: Promise<void> | null = null;
const warnedLegacyKeys = new Set<string>();
const warnedInvalidKeys = new Set<string>();
const warnedMissingKeys = new Set<string>();
// ========== 工具函数 ==========
const normalizeLangStrict = (lang?: string | null): string =>
String(lang || "")
.trim()
.toLowerCase();
const normalizeLang = (lang?: string | null): string =>
normalizeLangStrict(lang) || DEFAULT_I18N_LANG;
const isZhLang = (lang?: string | null): boolean =>
normalizeLang(lang).startsWith("zh");
const isLegacySystemKey = (key: string): boolean => key.startsWith("System.");
// Key 格式验证正则
// 格式: {Client}.{Scope}.{Domain}.{key} 或 {Client}.{Scope}.{key}
// Client: Claw|PC|Mobile
// Scope: 任意大写字母开头的标识符(如 Menu, Service, Agent, Client, App 等)
// Domain: 可选的点分隔路径(如 Status
// key: 任意字母开头的标识符(支持大小写)
const I18N_KEY_REGEX =
/^(Claw|PC|Mobile)\.[A-Z][A-Za-z0-9]*\.([A-Za-z0-9]+\.)*[A-Za-z][A-Za-z0-9]*$/;
const isValidI18nKey = (key: string): boolean => I18N_KEY_REGEX.test(key);
const warnOnce = (
cache: Set<string>,
key: string,
logger: (k: string) => void,
): void => {
if (cache.has(key)) return;
cache.add(key);
logger(key);
};
type I18nValues = (
| string
| number
| undefined
| Record<string, string | number | undefined>
)[];
const formatText = (template: string, values: I18nValues): string => {
if (!values.length) return template;
let text = template;
// 命名占位符t(key, { error: "xxx" }) → 替换 {error}
const namedValues = values.find(
(v): v is Record<string, string | number | undefined> =>
typeof v === "object" && v !== null,
);
if (namedValues) {
Object.entries(namedValues).forEach(([k, v]) => {
text = text.replace(new RegExp(`\\{${k}\\}`, "g"), String(v ?? ""));
});
return text;
}
// 位置占位符t(key, "a", "b") → 替换 {0} {1} 和 {}
const stringValues = values.map((v) => String(v ?? ""));
stringValues.forEach((value, index) => {
text = text.replace(new RegExp(`\\{${index}\\}`, "g"), value);
});
let cursor = 0;
text = text.replace(/\{\}/g, () => stringValues[cursor++] ?? "");
return text;
};
// ========== Electron Settings 存储 ==========
const getBrowserLang = (): string => {
if (typeof navigator === "undefined") {
return DEFAULT_I18N_LANG;
}
return normalizeLang(navigator.language);
};
const readFromSettings = async (key: string): Promise<string | null> => {
try {
const value = await window.electronAPI?.settings.get(key);
return value as string | null;
} catch {
return null;
}
};
const writeToSettings = async (key: string, value: string): Promise<void> => {
try {
await window.electronAPI?.settings.set(key, value);
} catch {
// ignore cache failures
}
};
/**
* 获取用户配置的服务器域名
* 优先使用 step1_config.serverHost用户登录时配置的域名
*/
const getUserDomain = async (): Promise<string | null> => {
try {
const step1 = (await window.electronAPI?.settings.get("step1_config")) as {
serverHost?: string;
} | null;
return step1?.serverHost || null;
} catch {
return null;
}
};
const readMapFromCache = async (
lang: string,
): Promise<SystemLangMap | null> => {
const cacheAtStr = await readFromSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_AT,
);
const cacheText = await readFromSettings(I18N_STORAGE_KEYS.LANG_MAP_CACHE);
const cacheLangRaw = await readFromSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_LANG,
);
const cacheAt = Number(cacheAtStr);
const cacheLang = cacheLangRaw ? normalizeLang(cacheLangRaw) : "";
if (!cacheText || !cacheAt) return null;
if (Date.now() - cacheAt > I18N_MAP_CACHE_TTL) return null;
if (cacheLang && cacheLang !== normalizeLang(lang)) return null;
try {
const cacheValue = JSON.parse(cacheText) as SystemLangMap;
if (cacheValue && typeof cacheValue === "object") {
return cacheValue;
}
} catch {
// ignore invalid cache
}
return null;
};
const persistMapCache = async (
lang: string,
map: SystemLangMap,
): Promise<void> => {
await writeToSettings(I18N_STORAGE_KEYS.LANG_MAP_CACHE, JSON.stringify(map));
await writeToSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_AT,
String(Date.now()),
);
await writeToSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_LANG,
normalizeLang(lang),
);
};
const readLangFromCache = async (): Promise<string | null> => {
return readFromSettings(I18N_STORAGE_KEYS.ACTIVE_LANG);
};
const readForceRefreshLangOnInit = async (): Promise<string> => {
const lang = await readFromSettings(
I18N_STORAGE_KEYS.LANG_FORCE_REFRESH_ON_INIT,
);
return normalizeLangStrict(lang);
};
// ========== API ==========
const buildZhValueToKeyMap = (map: SystemLangMap): void => {
const nextMap: Record<string, string> = {};
Object.entries(map).forEach(([key, value]) => {
const normalizedValue = String(value || "").trim();
if (!normalizedValue) return;
if (!(normalizedValue in nextMap)) {
nextMap[normalizedValue] = key;
}
});
zhValueToKeyMap = nextMap;
};
const fetchAndApplyLangMap = async (
lang?: string,
options?: { forceRefresh?: boolean },
): Promise<boolean> => {
const targetLang = normalizeLang(lang || currentLang);
const userDomain = await getUserDomain();
try {
const result = await apiRequest<SystemLangMap>("/api/i18n/query", {
method: "GET",
params: { lang: targetLang, side: "Claw" },
headers: {
"Accept-Language": targetLang,
},
cache: options?.forceRefresh ? "no-store" : undefined,
showError: false,
...(userDomain ? { baseUrl: userDomain } : {}),
});
const mergedMap = {
...getLocalBaseMap(targetLang),
...result,
};
if (normalizeLang(currentLang) === targetLang) {
langMap = mergedMap;
}
await persistMapCache(targetLang, mergedMap);
return true;
} catch {
return false;
}
};
/**
* 预拉取目标语言翻译并缓存到 DB。
* 用于语言切换 reload 前,确保 reload 后 readMapFromCache 能命中。
*/
export const prefetchLangMap = (lang: string): Promise<boolean> =>
fetchAndApplyLangMap(lang);
// ========== 导出函数 ==========
export const getCurrentLang = (): string => currentLang;
export const getCurrentLangMap = (): SystemLangMap => ({ ...langMap });
export const isCurrentLangSupported = (): boolean => isCurrentLangSupported_;
export const setCurrentLang = async (lang?: string | null): Promise<void> => {
const resolvedLang = normalizeLang(lang || getBrowserLang());
currentLang = resolvedLang;
isCurrentLangSupported_ = isLocaleSupported(resolvedLang);
langMap = { ...getLocalBaseMap(resolvedLang) };
await writeToSettings(I18N_STORAGE_KEYS.ACTIVE_LANG, resolvedLang);
};
export const initI18n = (): Promise<void> => {
if (initPromise) return initPromise;
initPromise = _doInitI18n();
return initPromise;
};
const _doInitI18n = async (): Promise<void> => {
const cachedLang = await readLangFromCache();
const resolvedLang = normalizeLang(cachedLang || getBrowserLang());
const forceRefreshLang = await readForceRefreshLangOnInit();
const shouldForceRefresh = forceRefreshLang === resolvedLang;
if (forceRefreshLang) {
await writeToSettings(I18N_STORAGE_KEYS.LANG_FORCE_REFRESH_ON_INIT, "");
}
await setCurrentLang(resolvedLang);
// 启动时同步主进程语言,避免主进程弹窗(如自动更新)与渲染进程语言不一致
try {
await window.electronAPI?.i18n?.setLang(resolvedLang);
} catch {
// ignore sync failures
}
const baseMap = getLocalBaseMap(resolvedLang);
langMap = { ...baseMap };
const cachedMap = await readMapFromCache(resolvedLang);
if (cachedMap) {
langMap = {
...baseMap,
...cachedMap,
};
}
if (isZhLang(resolvedLang)) {
zhBaseMap = { ...langMap };
buildZhValueToKeyMap(zhBaseMap);
} else if (!Object.keys(zhValueToKeyMap).length) {
buildZhValueToKeyMap(getLocaleMap("zh-cn"));
}
// 远端翻译改为后台刷新,避免首屏/切换语言时阻塞。
void (async () => {
const fetched = await fetchAndApplyLangMap(resolvedLang, {
forceRefresh: shouldForceRefresh,
});
if (isZhLang(resolvedLang)) {
if (normalizeLang(currentLang) === resolvedLang) {
zhBaseMap = { ...langMap };
buildZhValueToKeyMap(zhBaseMap);
}
}
// 非中文语言:使用本地 zh-CN.json 构建反向映射即可,无需额外请求服务端
if (!fetched && !cachedMap && normalizeLang(currentLang) === resolvedLang) {
langMap = { ...baseMap };
}
})().catch(() => {
// ignore background refresh failures
});
};
/**
* 标记下次初始化时强制 no-store 刷新当前语言翻译。
* 用于语言切换后刷新页面,避免切换流程被网络阻塞。
*/
export const scheduleLangMapRefreshOnNextInit = async (
lang?: string | null,
): Promise<void> => {
const targetLang = normalizeLangStrict(lang || currentLang);
if (!targetLang) return;
await writeToSettings(
I18N_STORAGE_KEYS.LANG_FORCE_REFRESH_ON_INIT,
targetLang,
);
};
/**
* 切换语言后强制刷新翻译映射(绕过浏览器缓存)。
* 中文语言同步更新反向映射;非中文语言的反向映射由本地 zh-CN.json 提供。
* 返回值表示是否成功从服务端拉取到最新翻译。
*/
export const refreshLangMap = async (
lang?: string | null,
): Promise<boolean> => {
const targetLang = normalizeLang(lang || currentLang);
await setCurrentLang(targetLang);
const fetched = await fetchAndApplyLangMap(targetLang, {
forceRefresh: true,
});
if (isZhLang(targetLang)) {
zhBaseMap = { ...langMap };
buildZhValueToKeyMap(zhBaseMap);
}
// 非中文语言:本地 zh-CN.json 反向映射已由 _doInitI18n 构建,无需额外请求
if (!Object.keys(zhValueToKeyMap).length) {
buildZhValueToKeyMap(getLocaleMap("zh-cn"));
}
return fetched;
};
/**
* 多语言翻译函数
* @param key 翻译 key格式{Client}.{Scope}.{Domain}.{key}
* @param values 替换参数:
* - 位置占位符t(key, "a", "b") → 替换 {0}/{1} 或 {}
* - 命名占位符t(key, { error: "xxx" }) → 替换 {error}
*/
export const dict = (key: string, ...values: I18nValues): string => {
const normalizedKey = String(key || "").trim();
if (!normalizedKey) return "";
if (isLegacySystemKey(normalizedKey)) {
warnOnce(warnedLegacyKeys, normalizedKey, (k) => {
console.error(
`[i18n] Legacy key is not supported anymore and should be migrated: ${k}`,
);
});
return normalizedKey;
}
if (!isValidI18nKey(normalizedKey)) {
warnOnce(warnedInvalidKeys, normalizedKey, (k) => {
console.error(
`[i18n] Invalid key format. Expected {Client}.{Scope}.{Domain}.{key}: ${k}`,
);
});
return normalizedKey;
}
let template = langMap[normalizedKey];
if (!template && isCurrentLangSupported()) {
template =
getLocaleMap("en")[normalizedKey] || getLocaleMap("zh-cn")[normalizedKey];
}
if (!template) {
warnOnce(warnedMissingKeys, normalizedKey, (k) => {
console.error(`[i18n] Missing translation entry for key: ${k}`);
});
return normalizedKey;
}
return formatText(template, values);
};
/**
* dict 的别名
*/
export const t = (key: string, ...values: I18nValues): string =>
dict(key, ...values);
/**
* 获取语言列表Promise 缓存,避免 SettingsPage useEffect 多次触发重复请求)
*/
let langListPromise: Promise<I18nLangDto[]> | null = null;
export async function fetchI18nLangList(): Promise<I18nLangDto[]> {
if (langListPromise) return langListPromise;
langListPromise = (async () => {
const userDomain = await getUserDomain();
const result = await apiRequest<I18nLangDto[]>("/api/i18n/lang/list", {
method: "GET",
showError: false,
...(userDomain ? { baseUrl: userDomain } : {}),
});
return result || [];
})();
return langListPromise;
}
/**
* 翻译原始中文文本(基于中文到 key 的反向映射)
*/
export const translateLiteralText = (rawText: string): string => {
const originalText = String(rawText || "");
const trimmedText = originalText.trim();
if (!trimmedText) return originalText;
// 直接支持新规范 key 文本
if (isValidI18nKey(trimmedText)) {
return originalText.replace(trimmedText, dict(trimmedText));
}
if (isLegacySystemKey(trimmedText)) {
dict(trimmedText);
return originalText;
}
// 中文界面无需替换
if (getCurrentLang().startsWith("zh")) {
return originalText;
}
const key = zhValueToKeyMap[trimmedText];
if (!key) return originalText;
const translated = dict(key);
if (!translated || translated === key) return originalText;
return originalText.replace(trimmedText, translated);
};

View File

@@ -0,0 +1,396 @@
/**
* Setup & Auth Service for QimingClaw
*
* Manages:
* - Setup Wizard (first launch)
* - Login/Logout
* - Service configuration
* - Persistence
*/
import { message } from "antd";
import {
DEFAULT_SERVER_HOST,
DEFAULT_AGENT_RUNNER_PORT,
DEFAULT_FILE_SERVER_PORT,
DEFAULT_GUI_MCP_PORT,
DEFAULT_ADMIN_SERVER_PORT,
STORAGE_KEYS,
AUTH_KEYS,
DEFAULT_AI_ENGINE,
} from "@shared/constants";
import { logger } from "../utils/logService";
// ==================== Types =============
export interface Step1Config {
serverHost: string;
agentPort: number;
fileServerPort: number;
guiMcpPort: number;
guiMcpEnabled: boolean;
adminServerPort?: number;
workspaceDir: string;
}
export interface AuthUserInfo {
id?: number;
username: string;
displayName?: string;
token?: string;
userId?: string;
email?: string;
currentDomain?: string;
}
export interface SetupState {
completed: boolean;
currentStep: number;
step1Completed: boolean;
step2Completed: boolean;
}
export interface ServiceStatus {
agent: {
running: boolean;
pid?: number;
port?: number;
};
fileServer: {
running: boolean;
port?: number;
};
}
// ==================== Default Values =============
export const DEFAULT_STEP1_CONFIG: Step1Config = {
serverHost: DEFAULT_SERVER_HOST,
agentPort: DEFAULT_AGENT_RUNNER_PORT,
fileServerPort: DEFAULT_FILE_SERVER_PORT,
guiMcpPort: DEFAULT_GUI_MCP_PORT,
guiMcpEnabled: false,
adminServerPort: DEFAULT_ADMIN_SERVER_PORT,
workspaceDir: "",
};
export const DEFAULT_SETUP_STATE: SetupState = {
completed: false,
currentStep: 1,
step1Completed: false,
step2Completed: false,
};
// ==================== Auth Keys (Re-export) =============
export { AUTH_KEYS };
// ==================== Storage Keys (Re-export) =============
export { STORAGE_KEYS };
// ==================== Setup Service =============
class SetupService {
/**
* Check if setup is completed
*/
async isSetupCompleted(): Promise<boolean> {
try {
const state = await this.getSetupState();
return state.completed;
} catch (error) {
console.error("[Setup] Check failed:", error);
logger.error("Check failed", "Setup", error);
return false;
}
}
/**
* Get current setup state
*/
async getSetupState(): Promise<SetupState> {
try {
const state = await window.electronAPI?.settings.get(
STORAGE_KEYS.SETUP_STATE,
);
return state
? { ...DEFAULT_SETUP_STATE, ...(state as SetupState) }
: DEFAULT_SETUP_STATE;
} catch (error) {
console.error("[Setup] Get state failed:", error);
logger.error("Get state failed", "Setup", error);
return DEFAULT_SETUP_STATE;
}
}
/**
* Get Step 1 config
*/
async getStep1Config(): Promise<Step1Config> {
try {
const config = await window.electronAPI?.settings.get(
STORAGE_KEYS.STEP1_CONFIG,
);
return config
? { ...DEFAULT_STEP1_CONFIG, ...(config as Step1Config) }
: DEFAULT_STEP1_CONFIG;
} catch (error) {
console.error("[Setup] Get Step1 failed:", error);
logger.error("Get Step1 failed", "Setup", error);
return DEFAULT_STEP1_CONFIG;
}
}
/**
* Save Step 1 config
*/
async saveStep1Config(config: Step1Config): Promise<void> {
try {
await window.electronAPI?.settings.set(STORAGE_KEYS.STEP1_CONFIG, config);
// Mark step 1 as completed
const state = await this.getSetupState();
state.step1Completed = true;
state.currentStep = 2;
await window.electronAPI?.settings.set(STORAGE_KEYS.SETUP_STATE, state);
console.log("[Setup] Step 1 saved:", config);
logger.info("Step 1 saved", "Setup", config);
} catch (error) {
console.error("[Setup] Save Step1 failed:", error);
logger.error("Save Step1 failed", "Setup", error);
throw error;
}
}
/**
* Complete Step 2 (login)
*/
async completeStep2(): Promise<void> {
try {
const state = await this.getSetupState();
state.step2Completed = true;
state.currentStep = 3;
await window.electronAPI?.settings.set(STORAGE_KEYS.SETUP_STATE, state);
logger.info("Step 2 completed", "Setup");
} catch (error) {
console.error("[Setup] Complete Step2 failed:", error);
logger.error("Complete Step2 failed", "Setup", error);
throw error;
}
}
/**
* Complete entire setup
*/
async completeSetup(): Promise<void> {
try {
const state = await this.getSetupState();
state.completed = true;
state.currentStep = 0;
await window.electronAPI?.settings.set(STORAGE_KEYS.SETUP_STATE, state);
logger.info("Setup completed", "Setup");
} catch (error) {
console.error("[Setup] Complete failed:", error);
logger.error("Complete failed", "Setup", error);
throw error;
}
}
/**
* Reset setup (for logout/re-setup)
* 清除所有设置向导状态和配置,恢复到初始状态
*/
async resetSetup(): Promise<void> {
try {
await window.electronAPI?.settings.set(
STORAGE_KEYS.SETUP_STATE,
DEFAULT_SETUP_STATE,
);
await window.electronAPI?.settings.set(STORAGE_KEYS.STEP1_CONFIG, null);
await window.electronAPI?.settings.set(STORAGE_KEYS.AGENT_CONFIG, null);
await window.electronAPI?.settings.set(
STORAGE_KEYS.LANPROXY_CONFIG,
null,
);
await window.electronAPI?.settings.set(STORAGE_KEYS.MCP_CONFIG, null);
logger.info("Reset completed", "Setup");
} catch (error) {
console.error("[Setup] Reset failed:", error);
logger.error("Reset failed", "Setup", error);
throw error;
}
}
}
// ==================== Auth Service =============
class AuthService {
/**
* Get saved user info (from auth.user_info, set by auth.ts)
*/
async getAuthUser(): Promise<AuthUserInfo | null> {
try {
const user = await window.electronAPI?.settings.get("auth.user_info");
return user as AuthUserInfo | null;
} catch (error) {
console.error("[Auth] Get user failed:", error);
logger.error("Get user failed", "Auth", error);
return null;
}
}
/**
* Clear auth (logout) — delegates to auth.ts logout()
*/
async clearAuth(): Promise<void> {
try {
await window.electronAPI?.settings.set("auth.username", null);
await window.electronAPI?.settings.set("auth.password", null);
await window.electronAPI?.settings.set("auth.config_key", null);
await window.electronAPI?.settings.set("auth.user_info", null);
await window.electronAPI?.settings.set("auth.online_status", null);
logger.info("Cleared", "Auth");
} catch (error) {
console.error("[Auth] Clear failed:", error);
logger.error("Clear failed", "Auth", error);
throw error;
}
}
/**
* Logout and optionally reset setup
*/
async logout(resetSetupState: boolean = false): Promise<void> {
await this.clearAuth();
if (resetSetupState) {
await setupService.resetSetup();
}
}
}
// ==================== Service Manager =============
class ServiceManager {
/**
* Get all services status
*/
async getStatus(): Promise<ServiceStatus> {
const status: ServiceStatus = {
agent: { running: false },
fileServer: { running: false },
};
try {
// Check Agent via SDK serviceStatus
const agentStatus = await window.electronAPI?.agent.serviceStatus();
if (agentStatus) {
status.agent.running = agentStatus.running;
}
// Check File Server (if implemented)
// const fsStatus = await window.electronAPI?.fileServer.status();
} catch (error) {
console.error("[Service] Get status failed:", error);
logger.error("Get status failed", "Service", error);
}
return status;
}
/**
* Start Agent service via SDK init
*/
async startAgent(config: {
engine?: string;
apiKey?: string;
baseUrl?: string;
model?: string;
workspaceDir?: string;
}): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.agent.init({
engine: (config.engine || DEFAULT_AI_ENGINE) as AgentEngineType,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
model: config.model,
workspaceDir: config.workspaceDir || "",
// mcpServers auto-injected by agent:init handler
})) || { success: false, error: "IPC failed" }
);
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Stop Agent service via SDK destroy
*/
async stopAgent(): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.agent.destroy()) || {
success: false,
error: "IPC failed",
}
);
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Start File Server
*/
async startFileServer(
port: number = 60000,
): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.fileServer?.start?.(port)) || {
success: false,
error: "Not implemented",
}
);
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Stop File Server
*/
async stopFileServer(): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.fileServer?.stop?.()) || {
success: false,
error: "Not implemented",
}
);
} catch (error) {
return { success: false, error: String(error) };
}
}
}
// ==================== Exports =============
export const setupService = new SetupService();
export const authService = new AuthService();
export const serviceManager = new ServiceManager();
export default {
setup: setupService,
auth: authService,
services: serviceManager,
};

View File

@@ -0,0 +1 @@
export * from './integrations/fileServer';

View File

@@ -0,0 +1,116 @@
/**
* i18n 配置
* 启动时从后端 /api/i18n/lang/list 拉取语言列表,动态扩展 i18next supportedLngs
*
* 注意:此文件仅用于初始化 i18next
* 实际翻译使用 @/services/core/i18n 中的 dict() 函数
*/
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { DEFAULT_SERVER_HOST } from "@shared/constants";
const STATIC_SUPPORTED_LNGS = [
"en",
"en-us",
"zh",
"zh-cn",
"zh-tw",
"zh-hk",
"en-US",
"zh-CN",
"zh-TW",
"zh-HK",
] as const;
interface I18nLangListItem {
lang?: string;
status?: number;
}
interface LangListResponse<T = unknown> {
code: string;
success?: boolean;
data: T;
}
function normalizeLangCode(lang: string): string {
return String(lang || "").trim();
}
function mergeSupportedLngs(extraLangs: string[]): string[] {
const merged = new Set<string>(STATIC_SUPPORTED_LNGS);
for (const lang of extraLangs) {
const raw = normalizeLangCode(lang);
if (!raw) continue;
merged.add(raw);
merged.add(raw.toLowerCase());
}
return [...merged];
}
async function getUserDomain(): Promise<string> {
try {
const step1 = (await window.electronAPI?.settings.get("step1_config")) as {
serverHost?: string;
} | null;
return step1?.serverHost || DEFAULT_SERVER_HOST;
} catch {
return DEFAULT_SERVER_HOST;
}
}
async function fetchSupportedLangsFromServer(): Promise<string[]> {
try {
const baseUrl = await getUserDomain();
const response = await fetch(`${baseUrl}/api/i18n/lang/list`, {
method: "GET",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return [];
const payload = (await response.json()) as LangListResponse<
I18nLangListItem[]
>;
if (payload?.code !== "0000" || !Array.isArray(payload.data)) return [];
return payload.data
.filter((item) => (item?.status ?? 0) === 1)
.map((item) => normalizeLangCode(item?.lang || ""))
.filter(Boolean);
} catch {
return [];
}
}
i18n.init({
lng: "en",
fallbackLng: "en",
supportedLngs: [...STATIC_SUPPORTED_LNGS],
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
let initSupportedLangsPromise: Promise<void> | null = null;
export function initSupportedLangs(): Promise<void> {
if (initSupportedLangsPromise) return initSupportedLangsPromise;
initSupportedLangsPromise = (async () => {
const dynamicLangs = await fetchSupportedLangsFromServer();
const nextSupportedLngs = mergeSupportedLngs(dynamicLangs);
i18n.options.supportedLngs = nextSupportedLngs;
})();
return initSupportedLangsPromise;
}
export default i18n;

View File

@@ -0,0 +1 @@
export * from './integrations/im';

View File

@@ -0,0 +1,21 @@
// Renderer process services
export { setupService, type Step1Config, DEFAULT_STEP1_CONFIG } from './core/setup';
export {
loginAndRegister,
logout,
getCurrentAuth,
getAuthErrorMessage,
syncConfigToServer,
isLoggedIn,
type AuthUserInfo,
} from './core/auth';
export { aiService } from './core/ai';
export { fileServerService } from './integrations/fileServer';
export { lanproxyManager } from './integrations/lanproxy';
export { agentRunnerManager } from './agents/agentRunner';
export { sandboxManager } from './agents/sandbox';
export { permissionManager } from './agents/permissions';
export { skillsService } from './integrations/skills';
export { imService } from './integrations/im';
export { taskScheduler } from './integrations/scheduler';
export { logService, exportLogs } from './utils/logService';

View File

@@ -0,0 +1,846 @@
import {
LOCALHOST_HOSTNAME,
DEFAULT_FILE_SERVER_PORT,
} from "@shared/constants";
/** 发送 PERF 日志到主进程写入专属日志文件,不可用时降级到 console.log */
function perfLog(msg: string): void {
if (window.electronAPI?.perf) {
window.electronAPI.perf.log(msg);
} else {
console.log(msg);
}
}
export interface FileServerConfig {
baseUrl: string;
apiKey?: string;
}
export interface WorkspaceResult {
success: boolean;
message: string;
workspaceRoot?: string;
}
export interface FileInfo {
name: string;
path: string;
isDirectory: boolean;
size?: number;
modifiedTime?: number;
}
export interface FileListResult {
success: boolean;
files: FileInfo[];
}
// ==================== Chat SSE Types ====================
export interface ChatMessage {
sessionId: string;
messageType: string;
subType: string;
data: any;
timestamp: string;
}
export interface ChatRequest {
user_id: string;
project_id?: string;
prompt: string;
session_id?: string;
attachments?: any[];
data_source_attachments?: string[];
model_provider?: ModelProviderConfig;
request_id?: string;
system_prompt?: string;
user_prompt?: string;
agent_config?: any;
}
export interface ModelProviderConfig {
provider: "anthropic" | "openai" | "google" | "azure";
api_key?: string;
base_url?: string;
model?: string;
max_tokens?: number;
temperature?: number;
}
export interface ChatResponse {
success: boolean;
project_id: string;
session_id: string;
message?: string;
error?: string;
}
export interface AgentStatusRequest {
user_id: string;
project_id?: string;
session_id?: string;
}
export interface AgentStatusResponse {
success: boolean;
status: "Idle" | "Busy";
session_id?: string;
project_id?: string;
}
export interface AgentStopRequest {
user_id: string;
project_id?: string;
session_id?: string;
}
export interface AgentStopResponse {
success: boolean;
message: string;
}
// ==================== File Server Service ====================
class FileServerService {
private config: FileServerConfig = {
baseUrl: `http://${LOCALHOST_HOSTNAME}:${DEFAULT_FILE_SERVER_PORT}`,
};
setConfig(config: Partial<FileServerConfig>) {
this.config = { ...this.config, ...config };
}
getConfig(): FileServerConfig {
return { ...this.config };
}
private getHeaders(): HeadersInit {
return this.config.apiKey
? { Authorization: `Bearer ${this.config.apiKey}` }
: {};
}
async loadConfig(): Promise<void> {
try {
const saved =
await window.electronAPI?.settings.get("file_server_config");
if (saved) {
this.config = { ...this.config, ...(saved as FileServerConfig) };
}
} catch (error) {
console.error("Failed to load file server config:", error);
}
}
async saveConfig(): Promise<void> {
try {
await window.electronAPI?.settings.set("file_server_config", this.config);
} catch (error) {
console.error("Failed to save file server config:", error);
}
}
// ==================== Chat/SSE API (Agent Runner) ====================
// POST /computer/chat - 发送聊天消息
// 注:当前 UI 为 embedded webviewJava 前端 → Java 后端 → Electron
// chat() / streamChat() 不经过此路径,[PERF][Frontend] 日志在实际链路中不触发。
// 代码保留供 Electron renderer 直连场景使用(如未来去除 webview 的方案)。
async chat(request: ChatRequest): Promise<ChatResponse> {
const t0 = Date.now();
// 优先通过 IPCAcpEngine 直接处理,返回 HttpResult<ComputerChatResponse>
if (window.electronAPI?.computer) {
perfLog(
`[PERF][Frontend][chat] request sent: project_id=${request.project_id}, t=${t0}`,
);
const result = await window.electronAPI.computer.chat(request);
const t1 = Date.now();
perfLog(
`[PERF][Frontend][chat] IPC response: session_id=${result.data?.session_id}, duration=${t1 - t0}ms`,
);
// 从 HttpResult 中提取 data映射到 fileServer 本地 ChatResponse 格式
return {
success: result.success,
project_id: result.data?.project_id || "",
session_id: result.data?.session_id || "",
error:
result.data?.error || (result.success ? undefined : result.message),
};
}
// 回退到 HTTPrcoder 返回 HttpResult<ChatResponse> 格式)
perfLog(
`[PERF][Frontend][chat] HTTP request sent: project_id=${request.project_id}, t=${t0}`,
);
const response = await fetch(`${this.config.baseUrl}/computer/chat`, {
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `Chat failed: ${response.statusText}`);
}
// rcoder 返回 HttpResult 格式,提取 data
const httpResult = await response.json();
const t1 = Date.now();
perfLog(
`[PERF][Frontend][chat] HTTP response: session_id=${httpResult.data?.session_id}, duration=${t1 - t0}ms`,
);
return {
success: httpResult.success ?? false,
project_id: httpResult.data?.project_id || "",
session_id: httpResult.data?.session_id || "",
error:
httpResult.data?.error ||
(httpResult.success ? undefined : httpResult.message),
};
}
// GET /computer/progress/{session_id} - SSE 流式进度
// 注:同 chat(),当前链路不经过此路径,[PERF][Frontend][streamChat] 日志在实际链路中不触发。
async *streamChat(sessionId: string): AsyncGenerator<ChatMessage> {
const t0 = Date.now();
perfLog(
`[PERF][Frontend][streamChat] connection initiated: session_id=${sessionId}, t=${t0}`,
);
const response = await fetch(
`${this.config.baseUrl}/computer/progress/${sessionId}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok) {
throw new Error(
`Failed to connect to progress stream: ${response.statusText}`,
);
}
if (!response.body) {
throw new Error("No response body");
}
perfLog(
`[PERF][Frontend][streamChat] SSE connected: session_id=${sessionId}, duration=${Date.now() - t0}ms`,
);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let firstChunk = true;
while (true) {
const { done, value } = await reader.read();
if (done) {
perfLog(
`[PERF][Frontend][streamChat] SSE ended: session_id=${sessionId}, total_duration=${Date.now() - t0}ms`,
);
break;
}
if (firstChunk) {
perfLog(
`[PERF][Frontend][streamChat] SSE first chunk: session_id=${sessionId}, duration=${Date.now() - t0}ms`,
);
firstChunk = false;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
// Skip heartbeat events
if (data.includes('"ping"') || data.includes("heartbeat")) {
continue;
}
try {
const message = JSON.parse(data);
yield message;
} catch {
// Not JSON, might be plain text
yield {
data,
messageType: "text",
subType: "message",
timestamp: new Date().toISOString(),
};
}
}
// Handle event types
if (line.startsWith("event: ")) {
const eventType = line.slice(7);
// Event type in 'event:' header
}
}
}
}
// POST /computer/agent/status - 获取 Agent 状态
async getAgentStatus(
request: AgentStatusRequest,
): Promise<AgentStatusResponse> {
// 优先通过 IPC返回 HttpResult<ComputerAgentStatusResponse>
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.agentStatus(request);
return {
success: result.success,
status: (result.data?.status === "Busy" ? "Busy" : "Idle") as
| "Idle"
| "Busy",
session_id: result.data?.session_id ?? undefined,
project_id: result.data?.project_id ?? request.project_id,
};
}
// 回退到 HTTPrcoder 返回 HttpResult 格式)
const response = await fetch(
`${this.config.baseUrl}/computer/agent/status`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
throw new Error(`Failed to get status: ${response.statusText}`);
}
const httpResult = await response.json();
return {
success: httpResult.success ?? false,
status: (httpResult.data?.status === "Busy" ? "Busy" : "Idle") as
| "Idle"
| "Busy",
session_id: httpResult.data?.session_id,
project_id: httpResult.data?.project_id,
};
}
// POST /computer/agent/stop - 停止 Agent
async stopAgent(request: AgentStopRequest): Promise<AgentStopResponse> {
// 优先通过 IPC返回 HttpResult<ComputerAgentStopResponse>
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.agentStop(request);
return {
success: result.data?.success ?? result.success,
message: result.data?.message ?? result.message,
};
}
// 回退到 HTTPrcoder 返回 HttpResult 格式)
const response = await fetch(`${this.config.baseUrl}/computer/agent/stop`, {
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Failed to stop agent: ${response.statusText}`);
}
const httpResult = await response.json();
return {
success: httpResult.data?.success ?? httpResult.success,
message: httpResult.data?.message ?? httpResult.message ?? "Stopped",
};
}
// POST /computer/agent/session/cancel - 取消会话
async cancelSession(request: {
user_id: string;
session_id: string;
}): Promise<{ success: boolean; message: string }> {
// 优先通过 IPC返回 HttpResult<ComputerAgentCancelResponse>
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.cancelSession(request);
return {
success: result.data?.success ?? result.success,
message: result.success ? "Cancelled" : result.message,
};
}
// 回退到 HTTPrcoder 返回 HttpResult 格式)
const response = await fetch(
`${this.config.baseUrl}/computer/agent/session/cancel`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
throw new Error(`Failed to cancel session: ${response.statusText}`);
}
const httpResult = await response.json();
return {
success: httpResult.data?.success ?? httpResult.success,
message: httpResult.success
? "Cancelled"
: (httpResult.message ?? "Failed"),
};
}
// ==================== Computer Routes ====================
// POST /computer/create-workspace - 创建工作空间支持skills同步
async createWorkspace(
userId: string,
cId: string,
zipFile?: File,
): Promise<WorkspaceResult> {
const formData = new FormData();
formData.append("userId", userId);
formData.append("cId", cId);
if (zipFile) {
formData.append("file", zipFile);
}
const response = await fetch(
`${this.config.baseUrl}/computer/create-workspace`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok) {
throw new Error(`Failed to create workspace: ${response.statusText}`);
}
return response.json();
}
// GET /computer/get-file-list - 获取文件列表
async getFileList(
userId: string,
cId: string,
proxyPath?: string,
): Promise<FileListResult> {
const params = new URLSearchParams({ userId, cId });
if (proxyPath) {
params.append("proxyPath", proxyPath);
}
const response = await fetch(
`${this.config.baseUrl}/computer/get-file-list?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok) {
throw new Error(`Failed to get file list: ${response.statusText}`);
}
return response.json();
}
// POST /computer/files-update - 批量更新文件
async updateFiles(
userId: string,
cId: string,
files: { name: string; contents: string }[],
): Promise<{ success: boolean }> {
const response = await fetch(
`${this.config.baseUrl}/computer/files-update`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, cId, files }),
},
);
if (!response.ok) {
throw new Error(`Failed to update files: ${response.statusText}`);
}
return response.json();
}
// POST /computer/upload-file - 上传单个文件
async uploadFile(
userId: string,
cId: string,
file: File,
filePath: string,
): Promise<any> {
const formData = new FormData();
formData.append("userId", userId);
formData.append("cId", cId);
formData.append("filePath", filePath);
formData.append("file", file);
const response = await fetch(
`${this.config.baseUrl}/computer/upload-file`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok) {
throw new Error(`Failed to upload file: ${response.statusText}`);
}
return response.json();
}
// POST /computer/upload-files - 批量上传文件
async uploadFiles(
userId: string,
cId: string,
files: File[],
filePaths: string[],
): Promise<any> {
const formData = new FormData();
formData.append("userId", userId);
formData.append("cId", cId);
formData.append("filePaths", JSON.stringify(filePaths));
files.forEach((file, i) => {
formData.append("files", file);
});
const response = await fetch(
`${this.config.baseUrl}/computer/upload-files`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok) {
throw new Error(`Failed to upload files: ${response.statusText}`);
}
return response.json();
}
// GET /computer/download-all-files - 下载所有文件
async downloadAllFiles(userId: string, cId: string): Promise<Blob> {
const params = new URLSearchParams({ userId, cId });
const response = await fetch(
`${this.config.baseUrl}/computer/download-all-files?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok) {
throw new Error(`Failed to download files: ${response.statusText}`);
}
return response.blob();
}
// ==================== Build Routes ====================
async startDev(projectId: string): Promise<any> {
const params = new URLSearchParams({ projectId });
const response = await fetch(
`${this.config.baseUrl}/build/start-dev?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to start dev: ${response.statusText}`);
return response.json();
}
async stopDev(projectId: string, pid: string): Promise<any> {
const params = new URLSearchParams({ projectId, pid });
const response = await fetch(
`${this.config.baseUrl}/build/stop-dev?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to stop dev: ${response.statusText}`);
return response.json();
}
async build(projectId: string): Promise<any> {
const params = new URLSearchParams({ projectId });
const response = await fetch(
`${this.config.baseUrl}/build/build?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to build: ${response.statusText}`);
return response.json();
}
async restartDev(projectId: string): Promise<any> {
const params = new URLSearchParams({ projectId });
const response = await fetch(
`${this.config.baseUrl}/build/restart-dev?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to restart dev: ${response.statusText}`);
return response.json();
}
async listDev(): Promise<{ success: boolean; list: any[] }> {
const response = await fetch(`${this.config.baseUrl}/build/list-dev`, {
method: "GET",
headers: this.getHeaders(),
});
if (!response.ok)
throw new Error(`Failed to list dev: ${response.statusText}`);
return response.json();
}
async getDevLog(
projectId: string,
startIndex: number = 1,
logType: string = "temp",
): Promise<any> {
const params = new URLSearchParams({
projectId,
startIndex: String(startIndex),
logType,
});
const response = await fetch(
`${this.config.baseUrl}/build/get-dev-log?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to get dev log: ${response.statusText}`);
return response.json();
}
// ==================== Code Routes ====================
async updateAllFiles(
projectId: string,
codeVersion: string,
files: any[],
basePath?: string,
pid?: string,
): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/code/all-files-update`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion, files, basePath, pid }),
},
);
if (!response.ok)
throw new Error(`Failed to update files: ${response.statusText}`);
return response.json();
}
async uploadSingleCodeFile(
projectId: string,
codeVersion: string,
file: File,
filePath: string,
): Promise<any> {
const formData = new FormData();
formData.append("projectId", projectId);
formData.append("codeVersion", codeVersion);
formData.append("filePath", filePath);
formData.append("file", file);
const response = await fetch(
`${this.config.baseUrl}/code/upload-single-file`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok)
throw new Error(`Failed to upload code file: ${response.statusText}`);
return response.json();
}
async rollbackVersion(
projectId: string,
codeVersion: string,
rollbackTo: string,
): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/code/rollback-version`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion, rollbackTo }),
},
);
if (!response.ok)
throw new Error(`Failed to rollback: ${response.statusText}`);
return response.json();
}
// ==================== Project Routes ====================
async createProject(projectId: string): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/project/create-project`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId }),
},
);
if (!response.ok)
throw new Error(`Failed to create project: ${response.statusText}`);
return response.json();
}
async getProjectContent(
projectId: string,
command?: string,
proxyPath?: string,
): Promise<any> {
const params = new URLSearchParams({ projectId });
if (command) params.append("command", command);
if (proxyPath) params.append("proxyPath", proxyPath);
const response = await fetch(
`${this.config.baseUrl}/project/get-project-content?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to get project content: ${response.statusText}`);
return response.json();
}
async backupVersion(projectId: string, codeVersion: string): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/project/backup-current-version`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion }),
},
);
if (!response.ok)
throw new Error(`Failed to backup version: ${response.statusText}`);
return response.json();
}
async exportProject(
projectId: string,
codeVersion: string,
exportType: string = "zip",
config?: any,
): Promise<Blob> {
const response = await fetch(
`${this.config.baseUrl}/project/export-project`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion, exportType, config }),
},
);
if (!response.ok)
throw new Error(`Failed to export project: ${response.statusText}`);
return response.blob();
}
async deleteProject(projectId: string, pid?: string): Promise<any> {
const params = new URLSearchParams({ projectId });
if (pid) params.append("pid", pid);
const response = await fetch(
`${this.config.baseUrl}/project/delete-project?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to delete project: ${response.statusText}`);
return response.json();
}
// Check connection
async checkConnection(): Promise<boolean> {
try {
// 优先通过 IPC
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.health();
return result.status === "healthy";
}
// 回退到 HTTP
const response = await fetch(`${this.config.baseUrl}/health`, {
method: "GET",
});
return response.ok;
} catch {
return false;
}
}
}
export const fileServerService = new FileServerService();

View File

@@ -0,0 +1,316 @@
export type IMPlatform = 'discord' | 'telegram' | 'dingtalk' | 'feishu';
export interface IMConfig {
platform: IMPlatform;
enabled: boolean;
token?: string;
botToken?: string;
apiKey?: string;
apiSecret?: string;
webhookUrl?: string;
allowedUsers?: string[];
autoReply?: boolean;
}
export interface IMLogin {
platform: IMPlatform;
userId: string;
username: string;
displayName: string;
avatar?: string;
}
export interface IMMessage {
id: string;
platform: IMPlatform;
sender: IMLogin;
content: string;
timestamp: number;
channelId?: string;
groupId?: string;
isGroup: boolean;
replyTo?: string;
}
export interface IMSendOptions {
content: string;
channelId?: string;
userId?: string;
replyTo?: string;
markdown?: boolean;
}
class IMService {
private configs: Map<IMPlatform, IMConfig> = new Map();
private handlers: Map<IMPlatform, (message: IMMessage) => void> = new Map();
private connections: Map<IMPlatform, boolean> = new Map();
constructor() {
// Initialize with empty configs
this.configs.set('discord', { platform: 'discord', enabled: false });
this.configs.set('telegram', { platform: 'telegram', enabled: false });
this.configs.set('dingtalk', { platform: 'dingtalk', enabled: false });
this.configs.set('feishu', { platform: 'feishu', enabled: false });
}
getConfig(platform: IMPlatform): IMConfig | undefined {
return this.configs.get(platform);
}
setConfig(platform: IMPlatform, config: Partial<IMConfig>): void {
const current = this.configs.get(platform) || { platform, enabled: false };
this.configs.set(platform, { ...current, ...config });
}
getEnabledPlatforms(): IMPlatform[] {
return Array.from(this.configs.entries())
.filter(([_, config]) => config.enabled)
.map(([platform]) => platform);
}
isConnected(platform: IMPlatform): boolean {
return this.connections.get(platform) || false;
}
onMessage(platform: IMPlatform, handler: (message: IMMessage) => void): void {
this.handlers.set(platform, handler);
}
async connect(platform: IMPlatform): Promise<{ success: boolean; error?: string }> {
const config = this.configs.get(platform);
if (!config || !config.enabled) {
return { success: false, error: 'Platform not enabled' };
}
try {
// Platform-specific connection logic
switch (platform) {
case 'discord':
return await this.connectDiscord(config);
case 'telegram':
return await this.connectTelegram(config);
case 'dingtalk':
return await this.connectDingtalk(config);
case 'feishu':
return await this.connectFeishu(config);
default:
return { success: false, error: 'Unknown platform' };
}
} catch (error) {
return { success: false, error: String(error) };
}
}
async disconnect(platform: IMPlatform): Promise<void> {
this.connections.set(platform, false);
}
async sendMessage(platform: IMPlatform, options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
if (!this.connections.get(platform)) {
return { success: false, error: 'Not connected' };
}
try {
switch (platform) {
case 'discord':
return await this.sendDiscordMessage(options);
case 'telegram':
return await this.sendTelegramMessage(options);
case 'dingtalk':
return await this.sendDingtalkMessage(options);
case 'feishu':
return await this.sendFeishuMessage(options);
default:
return { success: false, error: 'Unknown platform' };
}
} catch (error) {
return { success: false, error: String(error) };
}
}
// Discord implementation
private async connectDiscord(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.botToken) {
return { success: false, error: 'Bot token required' };
}
// Test API connection
try {
const response = await fetch('https://discord.com/api/v10/users/@me', {
headers: { 'Authorization': `Bot ${config.botToken}` }
});
if (response.ok) {
this.connections.set('discord', true);
return { success: true };
}
return { success: false, error: 'Invalid bot token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendDiscordMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
const config = this.configs.get('discord');
if (!config?.botToken || !options.channelId) {
return { success: false, error: 'Missing configuration' };
}
try {
const response = await fetch(`https://discord.com/api/v10/channels/${options.channelId}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bot ${config.botToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: options.content })
});
return { success: response.ok };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Telegram implementation
private async connectTelegram(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.botToken) {
return { success: false, error: 'Bot token required' };
}
try {
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/getMe`);
const data = await response.json();
if (data.ok) {
this.connections.set('telegram', true);
return { success: true };
}
return { success: false, error: 'Invalid bot token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendTelegramMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
const config = this.configs.get('telegram');
if (!config?.botToken || !options.userId) {
return { success: false, error: 'Missing configuration' };
}
try {
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: options.userId,
text: options.content,
})
});
return { success: response.ok };
} catch (error) {
return { success: false, error: String(error) };
}
}
// DingTalk implementation
private async connectDingtalk(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.appKey || !config.appSecret) {
return { success: false, error: 'AppKey and AppSecret required' };
}
try {
// Get access token
const tokenResponse = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appKey: config.appKey,
appSecret: config.appSecret,
})
});
const data = await tokenResponse.json();
if (data.accessToken) {
this.connections.set('dingtalk', true);
return { success: true };
}
return { success: false, error: 'Failed to get access token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendDingtalkMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
// DingTalk webhook implementation
if (!options.userId) {
return { success: false, error: 'User ID required' };
}
return { success: true }; // Placeholder
}
// Feishu ( Lark ) implementation
private async connectFeishu(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.appId || !config.appSecret) {
return { success: false, error: 'AppId and AppSecret required' };
}
try {
const response = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: config.appId,
app_secret: config.appSecret,
})
});
const data = await response.json();
if (data.tenant_access_token) {
this.connections.set('feishu', true);
return { success: true };
}
return { success: false, error: 'Failed to get tenant token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendFeishuMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
if (!options.userId) {
return { success: false, error: 'User ID required' };
}
return { success: true }; // Placeholder
}
// Load/save config
async loadConfigs(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get('im_configs');
if (saved) {
const configs = saved as IMConfig[];
for (const config of configs) {
this.configs.set(config.platform, config);
}
}
} catch (error) {
console.error('Failed to load IM configs:', error);
}
}
async saveConfigs(): Promise<void> {
try {
const configs = Array.from(this.configs.values());
await window.electronAPI?.settings.set('im_configs', configs);
} catch (error) {
console.error('Failed to save IM configs:', error);
}
}
}
export const imService = new IMService();

View File

@@ -0,0 +1,141 @@
import { LOCALHOST_IP, DEFAULT_LANPROXY_PORT } from "@shared/constants";
import { t } from "../core/i18n";
export interface LanproxyConfig {
enabled: boolean;
serverIp: string;
serverPort: number;
ssl: boolean;
}
export const defaultLanproxyConfig: LanproxyConfig = {
enabled: false,
serverIp: LOCALHOST_IP,
serverPort: DEFAULT_LANPROXY_PORT,
ssl: true,
};
export interface LanproxyStatus {
running: boolean;
pid?: number;
error?: string;
}
class LanproxyManager {
private config: LanproxyConfig = { ...defaultLanproxyConfig };
private status: LanproxyStatus = { running: false };
private process: ReturnType<typeof setInterval> | null = null;
getConfig(): LanproxyConfig {
return { ...this.config };
}
setConfig(config: Partial<LanproxyConfig>) {
this.config = { ...this.config, ...config };
}
getStatus(): LanproxyStatus {
return { ...this.status };
}
async loadConfig(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get("lanproxy_config");
if (saved) {
this.config = {
...defaultLanproxyConfig,
...(saved as LanproxyConfig),
};
}
} catch (error) {
console.error("Failed to load lanproxy config:", error);
}
}
async saveConfig(): Promise<void> {
try {
await window.electronAPI?.settings.set("lanproxy_config", this.config);
} catch (error) {
console.error("Failed to save lanproxy config:", error);
}
}
// Start lanproxy via IPC — clientKey 从 auth.saved_key 读取,不由 LanproxyManager 管理
async start(): Promise<{ success: boolean; error?: string }> {
if (this.status.running) {
return { success: true };
}
try {
// clientKey 始终从 auth.saved_key 读取(参考 Tauri 客户端)
const clientKey = (await window.electronAPI?.settings.get(
"auth.saved_key",
)) as string | null;
if (!clientKey) {
return {
success: false,
error: t("Claw.Errors.loginFirstForClientKey"),
};
}
const result = await window.electronAPI?.lanproxy.start({
serverIp: this.config.serverIp,
serverPort: this.config.serverPort,
clientKey,
ssl: this.config.ssl,
});
if (result?.success) {
this.status.running = true;
}
return result || { success: false, error: "IPC failed" };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Stop lanproxy via IPC
async stop(): Promise<{ success: boolean; error?: string }> {
if (!this.status.running) {
return { success: true };
}
try {
const result = await window.electronAPI?.lanproxy.stop();
if (result?.success) {
this.status.running = false;
}
return result || { success: false, error: "IPC failed" };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Get status via IPC
async checkStatus(): Promise<LanproxyStatus> {
try {
const status = await window.electronAPI?.lanproxy.status();
this.status = status || { running: false };
return this.status;
} catch (error) {
return { running: false, error: String(error) };
}
}
// Start periodic status check
startStatusCheck(intervalMs: number = 5000) {
this.stopStatusCheck();
this.process = setInterval(() => {
this.checkStatus();
}, intervalMs);
}
// Stop periodic status check
stopStatusCheck() {
if (this.process) {
clearInterval(this.process);
this.process = null;
}
}
}
export const lanproxyManager = new LanproxyManager();

View File

@@ -0,0 +1,314 @@
import { DEFAULT_SCHEDULER_MINUTE_INTERVAL } from '@shared/constants';
export interface ScheduledTask {
id: string;
name: string;
description: string;
enabled: boolean;
schedule: TaskSchedule;
action: TaskAction;
lastRun?: number;
nextRun?: number;
status: 'idle' | 'running' | 'success' | 'error';
lastError?: string;
}
export interface TaskSchedule {
type: 'once' | 'interval' | 'cron';
// For once
timestamp?: number;
// For interval
intervalMs?: number;
// For cron
cron?: string;
}
export interface TaskAction {
type: 'message' | 'command' | 'webhook';
content: string;
// For webhook
url?: string;
method?: 'GET' | 'POST';
headers?: Record<string, string>;
}
export interface ScheduledTaskLog {
id: string;
taskId: string;
timestamp: number;
status: 'success' | 'error';
output?: string;
error?: string;
}
class TaskScheduler {
private tasks: Map<string, ScheduledTask> = new Map();
private timers: Map<string, NodeJS.Timeout> = new Map();
private logs: Map<string, ScheduledTaskLog[]> = new Map();
private maxLogsPerTask = 50;
constructor() {
// Load tasks from storage on init
}
// Create a new task
createTask(task: Omit<ScheduledTask, 'id' | 'status' | 'lastRun' | 'nextRun'>): ScheduledTask {
const id = crypto.randomUUID();
const newTask: ScheduledTask = {
...task,
id,
status: 'idle',
};
this.tasks.set(id, newTask);
this.scheduleTask(newTask);
return newTask;
}
// Update a task
updateTask(id: string, updates: Partial<ScheduledTask>): ScheduledTask | null {
const task = this.tasks.get(id);
if (!task) return null;
const updatedTask = { ...task, ...updates };
this.tasks.set(id, updatedTask);
// Reschedule if schedule changed
if (updates.schedule) {
this.unscheduleTask(id);
this.scheduleTask(updatedTask);
}
return updatedTask;
}
// Delete a task
deleteTask(id: string): boolean {
this.unscheduleTask(id);
return this.tasks.delete(id);
}
// Get all tasks
getTasks(): ScheduledTask[] {
return Array.from(this.tasks.values());
}
// Get task by ID
getTask(id: string): ScheduledTask | undefined {
return this.tasks.get(id);
}
// Enable/disable task
toggleTask(id: string, enabled: boolean): ScheduledTask | null {
const task = this.tasks.get(id);
if (!task) return null;
task.enabled = enabled;
if (enabled) {
this.scheduleTask(task);
} else {
this.unscheduleTask(id);
}
return task;
}
// Run task immediately
async runTask(id: string): Promise<{ success: boolean; error?: string }> {
const task = this.tasks.get(id);
if (!task) {
return { success: false, error: 'Task not found' };
}
task.status = 'running';
this.tasks.set(id, task);
try {
const result = await this.executeAction(task.action);
task.status = 'success';
task.lastRun = Date.now();
task.lastError = undefined;
this.tasks.set(id, task);
this.addLog(task.id, { success: true, output: result });
return { success: true };
} catch (error) {
task.status = 'error';
task.lastError = String(error);
this.tasks.set(id, task);
this.addLog(task.id, { success: false, error: String(error) });
return { success: false, error: String(error) };
}
}
// Schedule a task
private scheduleTask(task: ScheduledTask): void {
if (!task.enabled) return;
const now = Date.now();
let nextRun: number;
switch (task.schedule.type) {
case 'once':
if (task.schedule.timestamp && task.schedule.timestamp > now) {
nextRun = task.schedule.timestamp;
} else {
return; // Past timestamp, don't schedule
}
break;
case 'interval':
if (task.schedule.intervalMs) {
nextRun = now + task.schedule.intervalMs;
} else {
return;
}
break;
case 'cron':
// Simple cron implementation - just use interval for now
nextRun = now + 60000; // Default 1 minute
break;
default:
return;
}
task.nextRun = nextRun;
this.tasks.set(task.id, task);
const delay = Math.max(0, nextRun - now);
const timer = setTimeout(async () => {
await this.runTask(task.id);
// Reschedule for next run
if (task.enabled && task.schedule.type !== 'once') {
this.scheduleTask(task);
}
}, delay);
this.timers.set(task.id, timer);
}
// Unschedule a task
private unscheduleTask(id: string): void {
const timer = this.timers.get(id);
if (timer) {
clearTimeout(timer);
this.timers.delete(id);
}
const task = this.tasks.get(id);
if (task) {
task.nextRun = undefined;
this.tasks.set(id, task);
}
}
// Execute task action
private async executeAction(action: TaskAction): Promise<string> {
switch (action.type) {
case 'message':
// This would trigger the AI to send a message
return `Message: ${action.content}`;
case 'command':
// Execute shell command
return `Command: ${action.content}`;
case 'webhook':
// Make HTTP request
try {
const response = await fetch(action.url!, {
method: action.method || 'GET',
headers: action.headers,
body: action.method === 'POST' ? action.content : undefined,
});
return `Webhook response: ${response.status}`;
} catch (error) {
throw new Error(`Webhook failed: ${error}`);
}
default:
throw new Error('Unknown action type');
}
}
// Add log entry
private addLog(taskId: string, result: { success: boolean; output?: string; error?: string }): void {
const log: ScheduledTaskLog = {
id: crypto.randomUUID(),
taskId,
timestamp: Date.now(),
status: result.success ? 'success' : 'error',
output: result.output,
error: result.error,
};
const taskLogs = this.logs.get(taskId) || [];
taskLogs.unshift(log);
// Keep only last N logs
if (taskLogs.length > this.maxLogsPerTask) {
taskLogs.pop();
}
this.logs.set(taskId, taskLogs);
}
// Get logs for a task
getLogs(taskId: string): ScheduledTaskLog[] {
return this.logs.get(taskId) || [];
}
// Load tasks from storage
async loadTasks(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get('scheduled_tasks');
if (saved) {
const tasks = saved as ScheduledTask[];
for (const task of tasks) {
this.tasks.set(task.id, task);
if (task.enabled) {
this.scheduleTask(task);
}
}
}
} catch (error) {
console.error('Failed to load scheduled tasks:', error);
}
}
// Save tasks to storage
async saveTasks(): Promise<void> {
try {
const tasks = Array.from(this.tasks.values());
await window.electronAPI?.settings.set('scheduled_tasks', tasks);
} catch (error) {
console.error('Failed to save scheduled tasks:', error);
}
}
// Stop all tasks
stopAll(): void {
for (const id of this.timers.keys()) {
this.unscheduleTask(id);
}
}
}
export const taskScheduler = new TaskScheduler();
// Common preset schedules
export const presetSchedules = {
everyMinute: { type: 'interval' as const, intervalMs: DEFAULT_SCHEDULER_MINUTE_INTERVAL },
everyHour: { type: 'interval' as const, intervalMs: 3600000 },
everyDay: { type: 'interval' as const, intervalMs: 86400000 },
everyWeek: { type: 'interval' as const, intervalMs: 604800000 },
};

View File

@@ -0,0 +1,277 @@
export interface SkillDefinition {
id: string;
name: string;
description: string;
enabled: boolean;
requiresPermission?: boolean;
icon?: string;
}
export interface SkillExecutionResult {
success: boolean;
output?: string;
error?: string;
requiresPermission?: boolean;
permissionDetails?: {
type: 'command' | 'file' | 'network';
title: string;
description: string;
details: Record<string, unknown>;
};
}
export interface SkillContext {
workingDir?: string;
userId: string;
sessionId: string;
requirePermission?: (details: SkillExecutionResult['permissionDetails']) => Promise<boolean>;
}
export interface BaseSkill {
id: string;
name: string;
description: string;
enabled: boolean;
requiresPermission: boolean;
execute(input: string, context: SkillContext): Promise<SkillExecutionResult>;
}
// Built-in skills
export const builtInSkills: BaseSkill[] = [];
class WebSearchSkill implements BaseSkill {
id = 'web-search';
name = 'Web Search';
description = 'Search the web for information';
enabled = true;
requiresPermission = false;
async execute(input: string, _context: SkillContext): Promise<SkillExecutionResult> {
// TODO: Implement web search
return {
success: true,
output: `Search results for: ${input}`,
};
}
}
class CalculatorSkill implements BaseSkill {
id = 'calculator';
name = 'Calculator';
description = 'Perform mathematical calculations';
enabled = true;
requiresPermission = false;
async execute(input: string, _context: SkillContext): Promise<SkillExecutionResult> {
try {
const sanitized = input.replace(/[^0-9+\-*/.()%]/g, '');
// eslint-disable-next-line no-new-func
const result = new Function(`"use strict"; return (${sanitized})`)();
return {
success: true,
output: String(result),
};
} catch {
return {
success: false,
error: 'Invalid expression',
};
}
}
}
class FileReadSkill implements BaseSkill {
id = 'file-read';
name = 'File Read';
description = 'Read content from a file';
enabled = true;
requiresPermission = true;
async execute(input: string, context: SkillContext): Promise<SkillExecutionResult> {
const filePath = input.trim();
if (context.requirePermission) {
const approved = await context.requirePermission({
type: 'file',
title: 'Read File',
description: `Read content from file: ${filePath}`,
details: { file: filePath },
});
if (!approved) {
return {
success: false,
error: 'Permission denied',
};
}
}
return {
success: true,
output: `File read: ${filePath}`,
};
}
}
class CommandRunSkill implements BaseSkill {
id = 'command-run';
name = 'Command Run';
description = 'Run shell commands';
enabled = true;
requiresPermission = true;
async execute(input: string, context: SkillContext): Promise<SkillExecutionResult> {
if (context.requirePermission) {
const approved = await context.requirePermission({
type: 'command',
title: 'Run Command',
description: `Execute shell command: ${input}`,
details: { command: 'sh', args: ['-c', input], env: {} },
});
if (!approved) {
return {
success: false,
error: 'Permission denied',
};
}
}
return {
success: true,
output: `Command would execute: ${input}`,
};
}
}
class NetworkFetchSkill implements BaseSkill {
id = 'network-fetch';
name = 'Network Fetch';
description = 'Fetch content from a URL';
enabled = true;
requiresPermission = true;
async execute(input: string, context: SkillContext): Promise<SkillExecutionResult> {
const url = input.trim();
if (context.requirePermission) {
const approved = await context.requirePermission({
type: 'network',
title: 'Network Request',
description: `Fetch content from: ${url}`,
details: { url },
});
if (!approved) {
return {
success: false,
error: 'Permission denied',
};
}
}
return {
success: true,
output: `Would fetch: ${url}`,
};
}
}
// Skill manager
class SkillManager {
private skills: Map<string, BaseSkill> = new Map();
register(skill: BaseSkill) {
this.skills.set(skill.id, skill);
}
getSkill(id: string): BaseSkill | undefined {
return this.skills.get(id);
}
listSkills(): SkillDefinition[] {
return Array.from(this.skills.values()).map((s) => ({
id: s.id,
name: s.name,
description: s.description,
enabled: s.enabled,
requiresPermission: s.requiresPermission,
}));
}
async executeSkill(
skillId: string,
input: string,
context: SkillContext,
requirePermission?: SkillContext['requirePermission']
): Promise<SkillExecutionResult> {
const skill = this.skills.get(skillId);
if (!skill) {
return { success: false, error: `Skill not found: ${skillId}` };
}
if (!skill.enabled) {
return { success: false, error: `Skill disabled: ${skillId}` };
}
const ctx = { ...context, requirePermission };
// Check if permission is required but not provided
if (skill.requiresPermission && !requirePermission) {
const result = await skill.execute(input, ctx);
return {
...result,
requiresPermission: true,
};
}
return skill.execute(input, ctx);
}
async executeAuto(
input: string,
context: SkillContext,
requirePermission?: SkillContext['requirePermission']
): Promise<SkillExecutionResult | null> {
const lowerInput = input.toLowerCase();
// Calculator
if (/^\d+[\s+\-*/%()\d]+$/.test(input.replace(/\s/g, ''))) {
return this.executeSkill('calculator', input, context, requirePermission);
}
// Web search
if (lowerInput.startsWith('search:') || lowerInput.startsWith('查找:') || lowerInput.startsWith('搜索:')) {
const query = input.replace(/^(search|查找|搜索):\s*/i, '');
return this.executeSkill('web-search', query, context, requirePermission);
}
// Command (starts with !)
if (lowerInput.startsWith('!') || lowerInput.startsWith('run:')) {
const cmd = input.replace(/^(!|run:)\s*/i, '');
return this.executeSkill('command-run', cmd, context, requirePermission);
}
// File read (starts with cat:)
if (lowerInput.startsWith('cat:')) {
const file = input.replace(/^cat:\s*/i, '');
return this.executeSkill('file-read', file, context, requirePermission);
}
// Network fetch (starts with fetch:)
if (lowerInput.startsWith('fetch:') || lowerInput.startsWith('curl:')) {
const url = input.replace(/^(fetch|curl):\s*/i, '');
return this.executeSkill('network-fetch', url, context, requirePermission);
}
return null;
}
}
export const skillManager = new SkillManager();
// Register built-in skills
skillManager.register(new WebSearchSkill());
skillManager.register(new CalculatorSkill());
skillManager.register(new FileReadSkill());
skillManager.register(new CommandRunSkill());
skillManager.register(new NetworkFetchSkill());

View File

@@ -0,0 +1 @@
export * from './integrations/lanproxy';

View File

@@ -0,0 +1,2 @@
export * from './utils/logService';
export { default } from './utils/logService';

View File

@@ -0,0 +1,2 @@
export * from './agents/permissions';
export { default } from './agents/permissions';

View File

@@ -0,0 +1,2 @@
export * from './agents/sandbox';
export { default } from './agents/sandbox';

View File

@@ -0,0 +1 @@
export * from './integrations/scheduler';

View File

@@ -0,0 +1,2 @@
export * from './core/setup';
export { default } from './core/setup';

View File

@@ -0,0 +1 @@
export * from './integrations/skills';

View File

@@ -0,0 +1,100 @@
/**
* 手动启动服务流程单测:断言「先 reg 再启动」的调用顺序。
* 对应 L7启动代理服务前必须调用 /reg保证 lanproxy 使用最新 serverHost/serverPort。
*/
import { describe, it, expect, vi } from "vitest";
import { runManualStartService } from "./startServiceFlow";
describe("runManualStartService", () => {
it("应先调用 syncConfigToServer再调用 startServicekey=lanproxy", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockImplementation(async () => {
callOrder.push("sync");
});
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await runManualStartService(
"lanproxy",
{
syncConfigToServer: sync,
startService: start,
},
// 不传 callbacks测试基本流程
);
expect(sync).toHaveBeenCalledWith({ suppressToast: true });
expect(start).toHaveBeenCalledWith("lanproxy");
expect(callOrder).toEqual(["sync", "start"]);
});
it("其他 key 同样保持先 reg 再 startService 的顺序", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockImplementation(async () => {
callOrder.push("sync");
});
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await runManualStartService("agent", {
syncConfigToServer: sync,
startService: start,
});
expect(callOrder).toEqual(["sync", "start"]);
expect(start).toHaveBeenCalledWith("agent");
});
it("callbacks 应在 reg 调用期间触发", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockImplementation(async () => {
callOrder.push("sync");
});
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await runManualStartService(
"lanproxy",
{
syncConfigToServer: sync,
startService: start,
},
{
onRegStart: () => callOrder.push("regStart"),
onRegEnd: () => callOrder.push("regEnd"),
},
);
// regStart < sync < regEnd < start
expect(callOrder).toEqual(["regStart", "sync", "regEnd", "start"]);
});
it("reg 失败时仍应调用 onRegEnd", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockRejectedValue(new Error("reg failed"));
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await expect(
runManualStartService(
"lanproxy",
{
syncConfigToServer: sync,
startService: start,
},
{
onRegStart: () => callOrder.push("regStart"),
onRegEnd: () => callOrder.push("regEnd"),
},
),
).rejects.toThrow("reg failed");
expect(callOrder).toEqual(["regStart", "regEnd"]);
expect(start).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,42 @@
/**
* 手动启动服务流程:先 reg 同步配置,再执行启动。
* 用于「手动启动单个服务」按钮,保证 lanproxy 等使用最新 serverHost/serverPort。
* 抽离以便单测断言调用顺序(先 syncConfigToServer再 startService
*/
export interface ManualStartServiceDeps {
/** 同步配置到后端(/reg返回最新 serverHost/serverPort 等;返回值本流程未使用 */
syncConfigToServer: (opts?: { suppressToast?: boolean }) => Promise<unknown>;
/** 实际启动指定 key 的服务(内部会调 IPC 如 lanproxy.start */
startService: (key: string) => Promise<unknown>;
}
export interface ManualStartServiceCallbacks {
/** reg 调用开始时的回调 */
onRegStart?: () => void;
/** reg 调用结束时的回调(无论成功失败) */
onRegEnd?: () => void;
}
/**
* 先调用 reg 同步配置,再启动指定服务。
* 调用顺序保证syncConfigToServer 完成后再 startService(key)。
*
* @param key - 服务标识符(如 'lanproxy', 'agent', 'fileServer'
* @param deps - 依赖注入对象,包含 syncConfigToServer 和 startService 方法
* @param callbacks - 可选回调,用于在 reg 阶段显示 loading
* @throws 重新抛出 syncConfigToServer 或 startService 的错误
*/
export async function runManualStartService(
key: string,
deps: ManualStartServiceDeps,
callbacks?: ManualStartServiceCallbacks,
): Promise<void> {
callbacks?.onRegStart?.();
try {
await deps.syncConfigToServer({ suppressToast: true });
} finally {
callbacks?.onRegEnd?.();
}
await deps.startService(key);
}

View File

@@ -0,0 +1,321 @@
/**
* 日志服务 - Renderer 安全版本
*
* 功能:
* - 日志获取、过滤、导出
* - 实时日志订阅
* - 日志统计
* - 通过 IPC 转发到主进程日志
*/
import { t } from "../core/i18n";
// ==================== Types ====================
export type LogLevel = "error" | "warning" | "success" | "info";
export interface LogEntry {
id: string;
timestamp: string;
level: LogLevel;
message: string;
source?: string;
details?: unknown;
}
export interface LogStats {
total: number;
error: number;
warning: number;
success: number;
info: number;
}
export interface LogFilter {
level?: LogLevel | "all";
keyword?: string;
startTime?: string;
endTime?: string;
source?: string;
}
export type ExportFormat = "json" | "csv" | "txt";
// ==================== Log Store ====================
class LogStore {
private logs: LogEntry[] = [];
private maxLogs = 1000;
private persistMaxLogs = 100;
private subscribers: Set<(log: LogEntry) => void> = new Set();
private readonly storageKey = "app_logs_cache";
constructor() {
// 从 localStorage 加载历史日志renderer 可用)
this.loadFromStorage();
}
private getStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
private loadFromStorage(): void {
try {
const storage = this.getStorage();
if (!storage) return;
const raw = storage.getItem(this.storageKey);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
this.logs = parsed as LogEntry[];
}
} catch (error) {
console.error("[LogService] Failed to load logs:", error);
}
}
private saveToStorage(): void {
try {
const storage = this.getStorage();
if (!storage) return;
storage.setItem(
this.storageKey,
JSON.stringify(this.logs.slice(0, this.persistMaxLogs)),
);
} catch (error) {
console.error("[LogService] Failed to save logs:", error);
}
}
addLog(entry: Omit<LogEntry, "id" | "timestamp">): LogEntry {
const newLog: LogEntry = {
...entry,
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 9),
timestamp: new Date().toISOString(),
};
this.logs.unshift(newLog);
if (this.logs.length > this.maxLogs) {
this.logs = this.logs.slice(0, this.maxLogs);
}
// 保存到 localStorage
this.saveToStorage();
// 通知订阅者
this.subscribers.forEach((cb) => cb(newLog));
// 同步转发到主进程日志(避免 renderer 直接依赖 electron-log
const msg = `[${entry.source || "app"}]${entry.level === "success" ? " ✓" : ""} ${entry.message}`;
const level: "info" | "warn" | "error" =
entry.level === "error"
? "error"
: entry.level === "warning"
? "warn"
: "info";
if (typeof window !== "undefined") {
void window.electronAPI?.log?.write(level, msg, entry.details);
}
return newLog;
}
getLogs(filter?: LogFilter): LogEntry[] {
let result = [...this.logs];
if (!filter) return result;
if (filter.level && filter.level !== "all") {
result = result.filter((log) => log.level === filter.level);
}
if (filter.keyword) {
const keyword = filter.keyword.toLowerCase();
result = result.filter(
(log) =>
log.message.toLowerCase().includes(keyword) ||
log.source?.toLowerCase().includes(keyword),
);
}
if (filter.startTime) {
result = result.filter((log) => log.timestamp >= filter.startTime!);
}
if (filter.endTime) {
result = result.filter((log) => log.timestamp <= filter.endTime!);
}
if (filter.source) {
result = result.filter((log) => log.source === filter.source);
}
return result.sort(
(a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
}
getStats(): LogStats {
return {
total: this.logs.length,
error: this.logs.filter((l) => l.level === "error").length,
warning: this.logs.filter((l) => l.level === "warning").length,
success: this.logs.filter((l) => l.level === "success").length,
info: this.logs.filter((l) => l.level === "info").length,
};
}
clearLogs(): void {
this.logs = [];
this.saveToStorage();
}
subscribe(callback: (log: LogEntry) => void): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
getSources(): string[] {
const sources = new Set<string>();
this.logs.forEach((log) => {
if (log.source) sources.add(log.source);
});
return Array.from(sources);
}
}
export const logStore = new LogStore();
// ==================== Log Service ====================
/**
* 添加日志 (快捷方法)
*/
export function addLog(
level: LogLevel,
message: string,
source?: string,
details?: unknown,
): LogEntry {
return logStore.addLog({ level, message, source, details });
}
/**
* 获取日志
*/
export function getLogs(filter?: LogFilter): LogEntry[] {
return logStore.getLogs(filter);
}
/**
* 获取日志统计
*/
export function getLogStats(): LogStats {
return logStore.getStats();
}
/**
* 清空日志
*/
export function clearLogs(): void {
logStore.clearLogs();
}
/**
* 订阅实时日志
*/
export function subscribeLogs(callback: (log: LogEntry) => void): () => void {
return logStore.subscribe(callback);
}
/**
* 获取日志来源列表
*/
export function getLogSources(): string[] {
return logStore.getSources();
}
/**
* 导出日志
*/
export function exportLogs(format: ExportFormat = "json"): string {
const logs = logStore.getLogs();
switch (format) {
case "json":
return JSON.stringify(logs, null, 2);
case "csv": {
const headers = t("Claw.Log.csvHeader") + "\n";
const rows = logs
.map(
(log) =>
`${log.timestamp},${log.level},${log.source || ""},"${log.message.replace(/"/g, '""')}"`,
)
.join("\n");
return headers + rows;
}
case "txt":
return logs
.map(
(log) =>
`[${log.timestamp}] [${log.level.toUpperCase()}] ${log.source ? `[${log.source}] ` : ""}${log.message}`,
)
.join("\n");
default:
return JSON.stringify(logs, null, 2);
}
}
/**
* 获取日志文件路径
*/
export function getLogFilePath(): string {
return "localStorage:app_logs_cache";
}
// ==================== Quick Log Methods ====================
export const logger = {
error: (message: string, source?: string, details?: unknown) =>
addLog("error", message, source, details),
warn: (message: string, source?: string, details?: unknown) =>
addLog("warning", message, source, details),
warning: (message: string, source?: string, details?: unknown) =>
addLog("warning", message, source, details),
success: (message: string, source?: string, details?: unknown) =>
addLog("success", message, source, details),
info: (message: string, source?: string, details?: unknown) =>
addLog("info", message, source, details),
// debug 使用 info 级别LogStore 无 debug 级别),语义上表示诊断日志
debug: (message: string, source?: string, details?: unknown) =>
addLog("info", message, source, details),
log: (message: string, source?: string, details?: unknown) =>
addLog("info", message, source, details),
};
export default {
addLog,
getLogs,
getLogStats,
clearLogs,
subscribeLogs,
getLogSources,
exportLogs,
logger,
};

View File

@@ -0,0 +1,54 @@
/**
* Renderer 进程日志工具
* 通过 IPC 将日志写入主进程日志文件
*/
export type LogLevel = "info" | "warn" | "error";
export interface Logger {
info: (msg: string, ...args: unknown[]) => void;
warn: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
}
/**
* 创建带 scope 的日志记录器
* @param scope - 日志作用域,如 "SetupCheck", "DepsCheck", "AutoReconnect"
* @returns Logger 实例
*
* 使用示例:
* const log = createLogger("AutoReconnect");
* log.info("starting services");
* // 输出: [Renderer] [AutoReconnect] starting services
*/
export function createLogger(scope: string): Logger {
const prefix = `[Renderer] [${scope}]`;
return {
info: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("info", `${prefix} ${msg}`, ...args);
},
warn: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("warn", `${prefix} ${msg}`, ...args);
},
error: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("error", `${prefix} ${msg}`, ...args);
},
};
}
/**
* 默认日志记录器(无 scope
*/
export const rendererLog: Logger = {
info: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("info", `[Renderer] ${msg}`, ...args);
},
warn: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("warn", `[Renderer] ${msg}`, ...args);
},
error: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("error", `[Renderer] ${msg}`, ...args);
},
};
export default rendererLog;

View File

@@ -0,0 +1,558 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
buildRedirectUrl,
buildNewSessionUrl,
buildChatSessionUrl,
syncSessionCookie,
syncCookieAndGetRedirectUrl,
syncCookieAndGetNewSessionUrl,
syncCookieAndGetChatUrl,
persistTicketCookie,
} from "./sessionUrl";
const { mockSettings, mockSession, mockLogger } = vi.hoisted(() => ({
mockSettings: { get: vi.fn(), set: vi.fn() },
mockSession: { setCookie: vi.fn(), getCookie: vi.fn() },
mockLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.stubGlobal("window", {
electronAPI: { settings: mockSettings, session: mockSession },
});
const mockGetCurrentAuth = vi.fn();
vi.mock("../core/auth", () => ({
getCurrentAuth: (...args: unknown[]) => mockGetCurrentAuth(...args),
}));
vi.mock("./logService", () => ({
logger: mockLogger,
}));
beforeEach(() => {
vi.clearAllMocks();
mockSession.setCookie.mockResolvedValue({ success: true });
mockSession.getCookie.mockResolvedValue({ success: true, found: false });
});
describe("buildRedirectUrl", () => {
it("strips trailing slashes and builds correct URL", () => {
expect(buildRedirectUrl("https://example.com///", "user1")).toBe(
"https://example.com/api/sandbox/config/redirect/user1?hideMenu=true",
);
expect(buildRedirectUrl("https://example.com/", "user1")).toBe(
"https://example.com/api/sandbox/config/redirect/user1?hideMenu=true",
);
expect(buildRedirectUrl("https://example.com", "user1")).toBe(
"https://example.com/api/sandbox/config/redirect/user1?hideMenu=true",
);
});
it("works with numeric configId", () => {
expect(buildRedirectUrl("https://example.com", 42)).toBe(
"https://example.com/api/sandbox/config/redirect/42?hideMenu=true",
);
});
});
describe("buildNewSessionUrl", () => {
it("builds correct new-session URL", () => {
expect(buildNewSessionUrl("https://example.com", 42)).toBe(
"https://example.com/api/sandbox/config/redirect/new/42?hideMenu=true",
);
});
it("strips trailing slashes", () => {
expect(buildNewSessionUrl("https://example.com///", 42)).toBe(
"https://example.com/api/sandbox/config/redirect/new/42?hideMenu=true",
);
});
});
describe("buildChatSessionUrl", () => {
it("builds correct URL with session id", () => {
expect(buildChatSessionUrl("https://example.com", "sess-abc-123")).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-abc-123?hideMenu=true",
);
});
it("strips trailing slashes", () => {
expect(buildChatSessionUrl("https://example.com///", "s1")).toBe(
"https://example.com/api/sandbox/config/redirect/chat/s1?hideMenu=true",
);
});
});
describe("syncSessionCookie", () => {
it("不设 domain 和 secure由主进程根据 URL scheme 判断", async () => {
await syncSessionCookie("https://app.example.com:8080/path", "tok123");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "https://app.example.com:8080/path",
name: "ticket",
value: "tok123",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
expect(payload).not.toHaveProperty("secure");
});
it("invalid URL 也不设 domain 和 secure", async () => {
await syncSessionCookie("not-a-valid-url", "tok123");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "not-a-valid-url",
name: "ticket",
value: "tok123",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
expect(payload).not.toHaveProperty("secure");
});
it("host-only cookie — 不设 domain与 webview Set-Cookie 行为一致", async () => {
await syncSessionCookie("https://example.com", "tok");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).not.toHaveProperty("domain");
});
it("IPv4 host 也不设 domain", async () => {
await syncSessionCookie("http://127.0.0.1:8080", "tok-ip");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "http://127.0.0.1:8080",
name: "ticket",
value: "tok-ip",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
});
it("localhost 也不设 domain", async () => {
await syncSessionCookie("http://localhost:3000", "tok-local");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "http://localhost:3000",
name: "ticket",
value: "tok-local",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
});
});
describe("syncCookieAndGetRedirectUrl", () => {
it("returns null when not logged in", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: false,
userInfo: null,
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBeNull();
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("returns null when domain is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 1, username: "u" },
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBeNull();
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("returns null when userId is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { currentDomain: "https://example.com", username: "u" },
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBeNull();
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("returns redirect URL and syncs cookie when all present", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com",
name: "ticket",
value: "my-token",
}),
);
// host-only cookie不设 domain
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).not.toHaveProperty("domain");
});
it("clears local auth token after syncing to webview cookie", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
await syncCookieAndGetRedirectUrl();
expect(mockSession.setCookie).toHaveBeenCalled();
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
});
it("keeps local auth token when cookie sync fails", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
mockSession.setCookie.mockResolvedValue({
success: false,
error: "cookie write failed",
});
await expect(syncCookieAndGetRedirectUrl()).rejects.toThrow(
"cookie write failed",
);
expect(mockSettings.set).not.toHaveBeenCalledWith("auth.token", null);
});
it("returns URL without syncing cookie when token is null", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue(null);
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("overwrites cookie when domain cache token exists (regardless of existing cookie)", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockImplementation((key: string) => {
if (key === "auth.token") return Promise.resolve(null);
if (key === "auth.tokens.example.com") return Promise.resolve("my-token");
return Promise.resolve(null);
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
// 有 token 时无条件覆盖,不管现有 cookie 状态
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com",
name: "ticket",
value: "my-token",
}),
);
});
it("overwrites existing ticket when one-shot auth token is present", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("fresh-token");
mockSession.getCookie.mockResolvedValue({ success: true, found: true });
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
});
it("prints diagnostic logs regardless of NODE_ENV", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockLogger.debug).toHaveBeenCalledWith(
"[SessionUrl] Pre-session state",
"SessionUrl",
expect.objectContaining({
domain: "https://example.com",
hasToken: true,
}),
);
expect(mockLogger.debug).toHaveBeenCalledWith(
"[SessionUrl] ticket cookie synced successfully",
"SessionUrl",
expect.objectContaining({ domain: "https://example.com" }),
);
});
});
describe("syncCookieAndGetNewSessionUrl", () => {
it("returns null when not logged in", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: false,
userInfo: null,
});
const result = await syncCookieAndGetNewSessionUrl();
expect(result).toBeNull();
});
it("returns new-session URL and syncs cookie", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetNewSessionUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/new/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
});
});
describe("syncCookieAndGetChatUrl", () => {
it("returns null when not logged in", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: false,
userInfo: null,
});
const result = await syncCookieAndGetChatUrl("sess-1");
expect(result).toBeNull();
});
it("returns null when domain is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 1, username: "u" },
});
const result = await syncCookieAndGetChatUrl("sess-1");
expect(result).toBeNull();
});
it("returns chat URL and syncs cookie", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetChatUrl("sess-abc");
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-abc?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
});
it("returns chat URL even when user id is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetChatUrl("sess-no-id");
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-no-id?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
});
it("returns chat URL without cookie when token is null", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue(null);
const result = await syncCookieAndGetChatUrl("sess-abc");
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-abc?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
});
// --- JWT expiry & token cache clearing ---
/** Helper: build a minimal JWT-like string with a given exp (epoch seconds). */
function makeJwt(exp: number): string {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = btoa(JSON.stringify({ exp }));
return `${header}.${payload}.sig`;
}
function setupAuthWithDomain(domain = "https://example.com") {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: domain, username: "u" },
});
}
describe("syncCookieAndBuildUrl — JWT expiry guard", () => {
it("skips cookie sync when one-shot token is expired, clears AUTH_TOKEN only", async () => {
setupAuthWithDomain();
const expiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 60); // expired 60s ago
mockSettings.get.mockResolvedValue(expiredJwt);
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
// Only AUTH_TOKEN cleared, not domain cache
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
expect(mockSettings.set).not.toHaveBeenCalledWith(
"auth.tokens.example.com",
null,
);
});
it("skips cookie sync when domain cache token is expired, clears domain key only", async () => {
setupAuthWithDomain();
const expiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 60);
mockSettings.get.mockImplementation((key: string) => {
if (key === "auth.token") return Promise.resolve(null);
if (key === "auth.tokens.example.com") return Promise.resolve(expiredJwt);
return Promise.resolve(null);
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
// Only domain key cleared, not global AUTH_TOKEN
expect(mockSettings.set).toHaveBeenCalledWith(
"auth.tokens.example.com",
null,
);
expect(mockSettings.set).not.toHaveBeenCalledWith("auth.token", null);
});
it("proceeds with cookie sync when token is not expired", async () => {
setupAuthWithDomain();
const validJwt = makeJwt(Math.floor(Date.now() / 1000) + 3600); // expires in 1h
mockSettings.get.mockResolvedValue(validJwt);
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({ name: "ticket", value: validJwt }),
);
});
it("proceeds with cookie sync for non-JWT token (no exp field)", async () => {
setupAuthWithDomain();
mockSettings.get.mockResolvedValue("opaque-token-not-jwt");
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({ value: "opaque-token-not-jwt" }),
);
});
it("proceeds with cookie sync for malformed JWT", async () => {
setupAuthWithDomain();
mockSettings.get.mockResolvedValue("not-even..a..jwt");
const result = await syncCookieAndGetRedirectUrl();
expect(mockSession.setCookie).toHaveBeenCalled();
});
it("treats token as valid if expired within clock-skew buffer (30s)", async () => {
setupAuthWithDomain();
// Expired only 10 seconds ago — within the 30s grace period
const barelyExpiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 10);
mockSettings.get.mockResolvedValue(barelyExpiredJwt);
const result = await syncCookieAndGetRedirectUrl();
// Should still sync — not treated as expired
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({ value: barelyExpiredJwt }),
);
});
});
describe("persistTicketCookie", () => {
it("clears both AUTH_TOKEN and domain token cache after webview login", async () => {
mockSession.getCookie.mockResolvedValue({ found: true });
mockSession.flushStore = vi.fn().mockResolvedValue(undefined);
await persistTicketCookie("https://example.com");
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
expect(mockSettings.set).toHaveBeenCalledWith(
"auth.tokens.example.com",
null,
);
});
it("skips clearing when no ticket cookie is present", async () => {
mockSession.getCookie.mockResolvedValue({ found: false });
await persistTicketCookie("https://example.com");
expect(mockSettings.set).not.toHaveBeenCalled();
});
it("does not throw on failure, logs warning instead", async () => {
mockSession.getCookie.mockRejectedValue(new Error("cookie read failed"));
await expect(
persistTicketCookie("https://example.com"),
).resolves.toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
"[SessionUrl] persistTicketCookie failed",
"SessionUrl",
expect.objectContaining({ domain: "https://example.com" }),
);
});
});

View File

@@ -0,0 +1,287 @@
/**
* Shared utility for constructing session redirect URLs and syncing cookies.
* Used by both ClientPage and SessionsPage.
*
* Redirect URL patterns:
* /api/sandbox/config/redirect/{sandboxConfigId} - enter sandbox (开始会话)
* /api/sandbox/config/redirect/new/{sandboxConfigId} - create new session (新建会话)
* /api/sandbox/config/redirect/chat/{sessionId} - enter history session (进入历史会话)
*/
import { getCurrentAuth } from "../core/auth";
import { AUTH_KEYS } from "@shared/constants";
import { logger } from "./logService";
import { getDomainTokenKey } from "@shared/utils/domain";
/**
* 解析 JWT exp 字段,返回 ISO 时间字符串或 null。
* 仅读取 exp不验证签名。
*/
/** JWT 过期后仍视为有效的宽限时间(毫秒),用于容忍时钟偏移。 */
const JWT_EXPIRY_BUFFER_MS = 30_000;
function parseJwtExpDate(token: string): string | null {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payload = JSON.parse(
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
);
if (typeof payload.exp === "number") {
return new Date(payload.exp * 1000).toISOString();
}
} catch {
/* ignore */
}
return null;
}
/**
* Build redirect URL for entering the sandbox dashboard (开始会话).
*/
export function buildRedirectUrl(
domain: string,
configId: string | number,
): string {
const normalizedDomain = domain.replace(/\/+$/, "");
return `${normalizedDomain}/api/sandbox/config/redirect/${configId}?hideMenu=true`;
}
/**
* Build redirect URL for creating a new session (新建会话).
*/
export function buildNewSessionUrl(
domain: string,
configId: string | number,
): string {
const normalizedDomain = domain.replace(/\/+$/, "");
return `${normalizedDomain}/api/sandbox/config/redirect/new/${configId}?hideMenu=true`;
}
/**
* Build redirect URL for entering a history session (进入历史会话).
*/
export function buildChatSessionUrl(domain: string, sessionId: string): string {
const normalizedDomain = domain.replace(/\/+$/, "");
return `${normalizedDomain}/api/sandbox/config/redirect/chat/${sessionId}?hideMenu=true`;
}
export async function syncSessionCookie(
domain: string,
token: string,
): Promise<void> {
// 不设置 domain → host-only cookie与 webview 内 Set-Cookie 行为一致,
// 确保 webview 登录后能覆盖 Electron 侧写入的 ticket避免 count=2 冲突
// 不设置 secure → 由主进程根据 URL scheme 自动判断(支持 HTTP 场景)
const payload: {
url: string;
name: string;
value: string;
httpOnly: boolean;
} = {
url: domain,
name: "ticket",
value: token,
httpOnly: true,
};
const result = await window.electronAPI?.session.setCookie(payload);
if (!result?.success) {
// 只记录域名和错误,不记录 token 等敏感信息
throw new Error(result?.error || `session:setCookie failed for ${domain}`);
}
// 写入后立即回读,便于定位"已写入但页面仍未登录"的问题
try {
const verify = await window.electronAPI?.session.getCookie({
url: domain,
name: "ticket",
});
logger.debug("[SessionUrl] ticket cookie read-back result", "SessionUrl", {
domain,
found: !!verify?.found,
count: verify?.count ?? 0,
});
} catch (error) {
logger.debug("[SessionUrl] ticket cookie read-back error", "SessionUrl", {
domain,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Shared helper: check auth, sync cookie, then call buildUrl to produce the final URL.
*/
async function syncCookieAndBuildUrl<T>(
buildUrl: (domain: string, configId?: number) => T,
options?: { requireConfigId?: boolean },
): Promise<T | null> {
const requireConfigId = options?.requireConfigId ?? true;
const auth = await getCurrentAuth();
if (!auth.isLoggedIn) return null;
const domain = auth.userInfo?.currentDomain;
const configId = auth.userInfo?.id;
if (!domain) return null;
if (requireConfigId && !configId) return null;
const oneShotToken = (await window.electronAPI?.settings.get(
AUTH_KEYS.AUTH_TOKEN,
)) as string | null;
const domainTokenKey = getDomainTokenKey(domain);
let tokenSource: "one_shot" | "domain_cache" | "none" = "none";
let token = oneShotToken;
if (token) {
tokenSource = "one_shot";
} else {
token = (await window.electronAPI?.settings.get(domainTokenKey)) as
| string
| null;
if (token) tokenSource = "domain_cache";
}
logger.debug("[SessionUrl] Pre-session state", "SessionUrl", {
domain,
hasToken: !!token,
tokenSource,
});
if (!token) {
// reg 未返回 token → 不做任何操作(不清空现有 cookie
logger.debug(
"[SessionUrl] No token available, skipping ticket sync",
"SessionUrl",
{ domain },
);
return buildUrl(domain, configId);
}
// 检查缓存 token 是否已过期JWT only。若已过期清空缓存并跳过覆写
// 避免用旧 token 覆盖 webview 内重新登录后获得的有效 ticket防御性兜底
const expDate = parseJwtExpDate(token);
if (
expDate &&
new Date(expDate).getTime() + JWT_EXPIRY_BUFFER_MS <= Date.now()
) {
logger.info(
"[SessionUrl] Cached token expired, clearing cache to avoid overwriting fresh webview cookie",
"SessionUrl",
{ domain, expDate },
);
// Only clear the token source that was actually checked and found expired.
// Avoids clearing a valid one-shot token when domain cache is expired, and vice versa.
if (tokenSource === "one_shot") {
await window.electronAPI?.settings.set(AUTH_KEYS.AUTH_TOKEN, null);
} else {
await window.electronAPI?.settings.set(domainTokenKey, null);
}
return buildUrl(domain, configId);
}
// 有有效 token → 同步到 webview cookie
try {
await syncSessionCookie(domain, token);
// one-shot token 成功后清除,避免反复覆盖 webview 内 ticket
await window.electronAPI?.settings.set(AUTH_KEYS.AUTH_TOKEN, null);
logger.debug(
"[SessionUrl] ticket cookie synced successfully",
"SessionUrl",
{
domain,
tokenSource,
},
);
} catch (error) {
logger.debug("[SessionUrl] ticket cookie sync failed", "SessionUrl", {
domain,
error: error instanceof Error ? error.message : String(error),
});
// 失败时不要清空 token保留重试机会
logger.error(
"[SessionUrl] Cookie sync failed, keeping local token for retry",
"SessionUrl",
{
domain,
error: error instanceof Error ? error.message : String(error),
},
);
throw error;
}
return buildUrl(domain, configId);
}
/**
* Sync cookie and return the sandbox redirect URL (开始会话).
*/
export async function syncCookieAndGetRedirectUrl(): Promise<string | null> {
return syncCookieAndBuildUrl(buildRedirectUrl, { requireConfigId: true });
}
/**
* Sync cookie and return the new-session redirect URL (新建会话).
*/
export async function syncCookieAndGetNewSessionUrl(): Promise<string | null> {
return syncCookieAndBuildUrl(buildNewSessionUrl, { requireConfigId: true });
}
/**
* Sync cookie and return the chat-session redirect URL (进入历史会话).
*/
export async function syncCookieAndGetChatUrl(
sessionId: string,
): Promise<string | null> {
return syncCookieAndBuildUrl(
(domain) => buildChatSessionUrl(domain, sessionId),
{ requireConfigId: false },
);
}
/**
* 将 webview 内登录产生的 ticket cookie 刷到磁盘。
*
* webview 内 Set-Cookie 产生的 ticket 带有 Max-Age持久 cookie
* 但 Chromium 不保证立即写入磁盘。如果 Electron 在 flush 前退出(开发调试常见),
* cookie 会丢失。此函数主动调用 flushStore 确保持久化。
*
* 应在检测到 webview 登录成功(从 /login 跳转到非 login 页面)时调用。
*/
export async function persistTicketCookie(domain: string): Promise<void> {
try {
const result = await window.electronAPI?.session.getCookie({
url: domain,
name: "ticket",
});
if (!result?.found) {
logger.debug(
"[SessionUrl] persistTicketCookie: no ticket cookie, skipping",
"SessionUrl",
{ domain },
);
return;
}
// 主动刷盘,确保 Chromium 将 cookie 写入磁盘
await window.electronAPI?.session.flushStore();
logger.info(
"[SessionUrl] persistTicketCookie: cookie store flushed",
"SessionUrl",
{ domain },
);
// webview 内重新登录后,清除 settings 里的旧 token cache。
// 否则下次打开 webview 时syncCookieAndBuildUrl 会用过期的缓存 token
// 覆盖刚登录得到的新 ticket导致服务端再次拒绝并跳到登录页。
const domainTokenKey = getDomainTokenKey(domain);
await window.electronAPI?.settings.set(AUTH_KEYS.AUTH_TOKEN, null);
await window.electronAPI?.settings.set(domainTokenKey, null);
logger.info(
"[SessionUrl] persistTicketCookie: stale token cache cleared",
"SessionUrl",
{ domain },
);
} catch (error) {
logger.warn("[SessionUrl] persistTicketCookie failed", "SessionUrl", {
domain,
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -0,0 +1,37 @@
/**
* App 组件样式
* 包含顶部栏、布局等样式
*/
/* 顶部栏右侧信息区 */
.headerRight {
display: flex;
align-items: center;
gap: 12px;
}
/* 用户名 */
.username {
color: #6B7280;
font-size: 12px;
}
/* 状态徽章文字 */
.badgeText {
color: #6B7280;
font-size: 12px;
}
/* 就绪、繁忙状态:橙色小点 */
.badgeIdle :global(.ant-badge-status-dot) {
width: 6px;
height: 6px;
min-width: 6px;
}
/* Webview 模式下顶部栏左侧操作区(替代 logo */
.headerWebviewActions {
display: flex;
align-items: center;
gap: 6px;
}

View File

@@ -0,0 +1,228 @@
/**
* ClientPage 组件样式
* 包含页面、区块、服务行、登录表单等样式
* 支持亮色/暗色主题
*/
/* ========== 页面 ========== */
.page {
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
/* min-height: 0; */
}
/* ========== 区块 ========== */
.section {
background: var(--color-bg-section);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
margin-bottom: 20px;
overflow: hidden;
}
.sectionHeader {
display: flex;
flex-direction: row;
gap: 6px;
padding: 10px 16px;
height: 46px;
align-items: center;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-section-header);
}
.sectionTitle {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.sectionDescription {
font-size: 11px;
color: var(--color-text-tertiary);
margin-top: 2px;
}
.sectionBody {
padding: 16px;
position: relative;
}
/* ========== 服务区块头部 ========== */
.servicesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-section-header);
}
.servicesHeaderLeft {
display: flex;
align-items: center;
gap: 6px;
}
.servicesHeaderActions {
display: flex;
gap: 8px;
}
/* 停止按钮红色边框 */
.dangerButton {
border-color: var(--color-error) !important;
color: var(--color-error) !important;
}
.dangerButton:hover:not(:disabled) {
border-color: #DC2626 !important;
color: #DC2626 !important;
background: rgba(254, 242, 242, 0.8) !important;
}
[data-theme="dark"] .dangerButton:hover:not(:disabled) {
background: rgba(127, 29, 29, 0.3) !important;
}
/* ========== 服务行 ========== */
.serviceRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--color-border);
}
.serviceRow:last-child {
border-bottom: none;
}
.serviceInfo {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.serviceLabel {
font-size: 13px;
color: var(--color-text);
}
.serviceDescription {
font-size: 11px;
color: var(--color-text-tertiary);
margin-top: 2px;
margin-left: 4px;
}
.servicePid {
font-size: 11px;
color: var(--color-text-tertiary);
}
.serviceActions {
display: flex;
gap: 4px;
}
/* ========== 登录区块 ========== */
/* 已登录:左右布局 */
.loggedInContainer {
display: flex;
align-items: center;
justify-content: space-between;
}
.userInfo {
display: flex;
align-items: center;
gap: 8px;
}
.userInfoText {
display: flex;
flex-direction: column;
gap: 2px;
}
.username {
font-size: 13px;
font-weight: 500;
color: var(--color-text);
}
.domain {
font-size: 11px;
color: var(--color-text-secondary);
}
.actionButtons {
display: flex;
align-items: center;
gap: 4px;
}
/* 登录提示 */
.loginHint {
margin-top: 8px;
text-align: center;
}
.loginHintText {
font-size: 11px;
color: var(--color-text-tertiary);
}
/* 二维码弹窗 */
.qrCodeContainer {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
}
/* ========== Loading 覆盖层 ========== */
.loadingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
z-index: 10;
}
[data-theme="dark"] .loadingOverlay {
background: rgba(24, 24, 27, 0.9);
}
.loadingText {
font-size: 13px;
color: var(--color-text);
font-weight: 500;
}
/* ========== 快捷操作 ========== */
.quickActions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.dependencyAlert {
margin-bottom: 16px;
}
[data-theme="dark"] .dependencyAlert :global(.ant-alert-content){
:global(.ant-alert-message){
color: #18181b;
}
}

View File

@@ -0,0 +1,31 @@
/**
* EmbeddedWebview 组件样式
* 支持亮色/暗色主题
*/
.container {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-bg-section-header);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.url {
flex: 1;
font-size: 12px;
color: var(--color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,124 @@
/**
* SessionsPage 组件样式
* 包含会话列表视图和内嵌 webview 视图
* 支持亮色/暗色主题
*/
/* ========== 页面容器 ========== */
.page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ========== 视图 A: 会话列表 ========== */
.listView {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: auto;
}
/* 顶部操作栏 */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--color-bg-section-header);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.toolbarLeft {
display: flex;
align-items: center;
gap: 6px;
}
.toolbarTitle {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.toolbarActions {
display: flex;
gap: 8px;
}
/* 会话列表 */
.sessionList {
padding: 0 16px;
flex: 1;
}
.sessionRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--color-border);
}
.sessionRow:last-child {
border-bottom: none;
}
.sessionInfo {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.sessionTitle {
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionMeta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--color-text-tertiary);
}
.sessionActions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 12px;
}
/* 空状态 */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 16px;
color: var(--color-text-tertiary);
font-size: 13px;
gap: 12px;
}
.emptyIcon {
font-size: 36px;
opacity: 0.4;
}
/* ========== 视图 B: 全屏 webview (sidebar/logo hidden) ========== */
.webviewFullscreen {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}

View File

@@ -0,0 +1,266 @@
import type { ThemeConfig, MappingAlgorithm } from "antd";
import { theme } from "antd";
/**
* QimingClaw 主题配置
* shadcn/ui 风格,支持亮色/暗色主题
*/
// 通用 token不随主题变化的布局、字体、圆角等
const sharedToken = {
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
fontSize: 13,
fontSizeHeading1: 24,
fontSizeHeading2: 20,
fontSizeHeading3: 16,
fontSizeHeading4: 15,
fontSizeHeading5: 14,
lineWidth: 1,
controlHeight: 32,
controlHeightLG: 36,
controlHeightSM: 28,
boxShadow:
"0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02)",
boxShadowSecondary: "0 1px 2px 0 rgba(0, 0, 0, 0.03)",
paddingContentHorizontal: 16,
paddingContentVertical: 12,
motionDurationFast: "0.15s",
motionDurationMid: "0.2s",
};
// 通用组件配置(不随主题变化)
const sharedComponents = {
Button: {
borderRadius: 8,
fontWeight: 500,
primaryShadow: "none",
defaultShadow: "none",
dangerShadow: "none",
},
Card: {
borderRadiusLG: 10,
paddingLG: 16,
headerFontSize: 13,
headerFontSizeSM: 12,
},
Input: {
borderRadius: 8,
},
Select: {
borderRadius: 8,
},
Tag: {
borderRadiusSM: 6,
},
Alert: {
borderRadiusLG: 8,
},
Modal: {
borderRadiusLG: 10,
},
Menu: {
darkItemBg: "transparent",
darkSubMenuItemBg: "transparent",
itemBorderRadius: 8,
itemMarginInline: 6,
itemPaddingInline: 12,
},
Badge: {
dotSize: 6,
},
};
/**
* main.tsx 使用的根 ConfigProvider 配置
* 仅包含通用布局/字体 token不含颜色。
* 颜色由 App.tsx 中的动态 ConfigProvider 接管。
*/
export const rootTheme: ThemeConfig = {
token: {
...sharedToken,
borderRadius: 6,
borderRadiusLG: 8,
borderRadiusSM: 4,
},
components: {
...sharedComponents,
Tag: { borderRadiusSM: 4 },
},
};
// 亮色主题
export const lightTheme: ThemeConfig = {
algorithm: theme.defaultAlgorithm as MappingAlgorithm,
token: {
...sharedToken,
borderRadius: 8,
borderRadiusLG: 10,
borderRadiusSM: 6,
colorPrimary: "#18181b",
colorPrimaryHover: "#27272a",
colorPrimaryActive: "#3f3f46",
colorSuccess: "#16a34a",
colorSuccessHover: "#15803d",
colorSuccessActive: "#166534",
colorError: "#EF4444",
colorErrorHover: "#DC2626",
colorErrorActive: "#B91C1C",
colorWarning: "#F59E0B",
colorWarningHover: "#D97706",
colorWarningActive: "#B45309",
colorInfo: "#3B82F6",
colorBorder: "#E5E7EB",
colorBorderSecondary: "#F3F4F6",
colorBgContainer: "#ffffff",
colorBgLayout: "#F8F9FA",
colorBgElevated: "#ffffff",
colorText: "#18181b",
colorTextSecondary: "#6B7280",
colorTextTertiary: "#9CA3AF",
colorTextQuaternary: "#D1D5DB",
},
components: {
...sharedComponents,
Button: {
...sharedComponents.Button,
defaultBorderColor: "#e4e4e7",
defaultColor: "#18181b",
defaultBg: "#ffffff",
defaultHoverBg: "#f4f4f5",
defaultHoverColor: "#18181b",
defaultHoverBorderColor: "#d4d4d8",
},
Input: {
...sharedComponents.Input,
activeBorderColor: "#a1a1aa",
hoverBorderColor: "#a1a1aa",
activeShadow: "0 0 0 2px rgba(24, 24, 27, 0.06)",
},
Select: {
...sharedComponents.Select,
optionSelectedBg: "#f4f4f5",
},
Menu: {
...sharedComponents.Menu,
itemSelectedBg: "#f4f4f5",
itemSelectedColor: "#18181b",
},
Tabs: {
inkBarColor: "#18181b",
itemSelectedColor: "#18181b",
itemHoverColor: "#18181b",
},
Switch: {
colorPrimary: "#18181b",
colorPrimaryHover: "#27272a",
},
Steps: {
navArrowColor: "#18181b",
},
Table: {
headerBg: "#fafafa",
headerColor: "#71717a",
borderColor: "#f4f4f5",
},
Descriptions: {
labelBg: "#fafafa",
},
Progress: {
defaultColor: "#18181b",
},
},
};
// 暗色主题
export const darkTheme: ThemeConfig = {
algorithm: theme.darkAlgorithm as MappingAlgorithm,
token: {
...sharedToken,
borderRadius: 8,
borderRadiusLG: 10,
borderRadiusSM: 6,
colorPrimary: "#fafafa",
colorPrimaryHover: "#e4e4e7",
colorPrimaryActive: "#d4d4d8",
colorSuccess: "#22c55e",
colorSuccessHover: "#16a34a",
colorSuccessActive: "#15803d",
colorError: "#EF4444",
colorErrorHover: "#DC2626",
colorErrorActive: "#B91C1C",
colorWarning: "#F59E0B",
colorWarningHover: "#D97706",
colorWarningActive: "#B45309",
colorInfo: "#3B82F6",
colorBorder: "#27272a",
colorBorderSecondary: "#3f3f46",
colorBgContainer: "#18181b",
colorBgLayout: "#09090b",
colorBgElevated: "#27272a",
colorText: "#fafafa",
colorTextSecondary: "#a1a1aa",
colorTextTertiary: "#71717a",
colorTextQuaternary: "#52525b",
},
components: {
...sharedComponents,
Button: {
...sharedComponents.Button,
primaryColor: "#18181b",
defaultBg: "#27272a",
defaultBorderColor: "#3f3f46",
defaultColor: "#a1a1aa",
defaultHoverBg: "#3f3f46",
defaultHoverColor: "#fafafa",
defaultHoverBorderColor: "#52525b",
},
Input: {
...sharedComponents.Input,
colorBgContainer: "#27272a",
},
Select: {
...sharedComponents.Select,
colorBgContainer: "#27272a",
},
Menu: {
...sharedComponents.Menu,
itemSelectedBg: "#27272a",
itemSelectedColor: "#fafafa",
itemColor: "#a1a1aa",
itemHoverColor: "#fafafa",
itemHoverBg: "#3f3f46",
},
Modal: {
...sharedComponents.Modal,
colorBgElevated: "#27272a",
},
Tabs: {
inkBarColor: "#fafafa",
itemSelectedColor: "#fafafa",
itemHoverColor: "#fafafa",
itemColor: "#a1a1aa",
},
Switch: {
colorPrimary: "#3f3f46",
colorPrimaryHover: "#52525b",
},
Steps: {
navArrowColor: "#fafafa",
},
Table: {
colorBgContainer: "#18181b",
headerBg: "#27272a",
rowHoverBg: "#27272a",
},
},
};
// 兼容旧代码
export const appTheme = lightTheme;

View File

@@ -0,0 +1,40 @@
import { I18N_KEYS } from "@shared/constants";
import { t } from "../services/core/i18n";
interface DependencyNameInput {
name: string;
displayName?: string;
}
/**
* 统一解析依赖名称的展示文案(多语言兜底)。
*
* 设计目的:
* - 后端在个别异常场景可能返回未翻译 key甚至错误拼接的 key
* - 若前端直接展示该值,会把内部 key 暴露给用户,影响可读性;
* - 因此前端按稳定包名优先映射本地 i18n再回退后端字段。
*/
export function resolveDepDisplayName(dep: DependencyNameInput): string {
const keyByPackageName: Record<string, string> = {
uv: I18N_KEYS.Pages.Dependencies.DEP_UV,
pnpm: I18N_KEYS.Pages.Dependencies.DEP_PNPM,
"@anthropic-ai/sdk": I18N_KEYS.Pages.Dependencies.DEP_ANTHROPIC_SDK,
"claude-code-acp-ts": I18N_KEYS.Pages.Dependencies.DEP_CLAUDE_CODE_ACP,
"qiming-file-server": I18N_KEYS.Pages.Dependencies.DEP_FILE_SERVER,
"qiming-mcp-stdio-proxy": I18N_KEYS.Pages.Dependencies.DEP_MCP_PROXY,
qimingcode: I18N_KEYS.Pages.Dependencies.DEP_QIMINGCODE,
};
const mappedKey = keyByPackageName[dep.name];
if (mappedKey) return t(mappedKey);
const fallbackDisplayName = dep.displayName?.trim();
if (!fallbackDisplayName) return dep.name;
if (fallbackDisplayName.includes("Claw.Pages.Dependencies.dep.")) {
const translated = t(fallbackDisplayName);
return translated !== fallbackDisplayName ? translated : dep.name;
}
return fallbackDisplayName;
}

View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
/** 应用版本号,由 Vite define 从 package.json 注入 */
declare const __APP_VERSION__: string;
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module '*.css' {
const content: string;
export default content;
}