chore: initialize qiming workspace repository
This commit is contained in:
1377
qimingclaw/crates/agent-electron-client/src/renderer/App.tsx
Normal file
1377
qimingclaw/crates/agent-electron-client/src/renderer/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 event,callback 直接收到 (...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/Linux:Squirrel 不发送 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 始终为 true,onClose 不需要实际关闭
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. 调用 loginAndRegister(password 传空字符串,函数内部从 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;
|
||||
954
qimingclaw/crates/agent-electron-client/src/renderer/index.css
Normal file
954
qimingclaw/crates/agent-electron-client/src/renderer/index.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
142
qimingclaw/crates/agent-electron-client/src/renderer/main.tsx
Normal file
142
qimingclaw/crates/agent-electron-client/src/renderer/main.tsx
Normal 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));
|
||||
}
|
||||
|
||||
// 初始化 i18n(API 驱动 + 本地缓存)- 尽快开始
|
||||
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>,
|
||||
);
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './agents/agentRunner';
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core/ai';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core/api';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core/auth';
|
||||
@@ -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();
|
||||
@@ -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 传入 fetch(P0-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)→ 应抛出包含"请求超时"的错误,不暴露原始 AbortError(P0-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');
|
||||
});
|
||||
});
|
||||
@@ -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 超时后抛出 TimeoutError(name === '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,
|
||||
});
|
||||
}
|
||||
@@ -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,而非全局 key(P0-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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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_config(LanproxySettings 可编辑的配置)
|
||||
// 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);
|
||||
// 密码不持久化保存,savedKey(configKey)用于后续自动认证
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/fileServer';
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/im';
|
||||
@@ -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';
|
||||
@@ -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 webview(Java 前端 → Java 后端 → Electron),
|
||||
// chat() / streamChat() 不经过此路径,[PERF][Frontend] 日志在实际链路中不触发。
|
||||
// 代码保留供 Electron renderer 直连场景使用(如未来去除 webview 的方案)。
|
||||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||||
const t0 = Date.now();
|
||||
// 优先通过 IPC(AcpEngine 直接处理,返回 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),
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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,
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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,
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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,
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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());
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/lanproxy';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './utils/logService';
|
||||
export { default } from './utils/logService';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './agents/permissions';
|
||||
export { default } from './agents/permissions';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './agents/sandbox';
|
||||
export { default } from './agents/sandbox';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/scheduler';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './core/setup';
|
||||
export { default } from './core/setup';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/skills';
|
||||
@@ -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,再调用 startService(key=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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
14
qimingclaw/crates/agent-electron-client/src/renderer/vite-env.d.ts
vendored
Normal file
14
qimingclaw/crates/agent-electron-client/src/renderer/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user