chore: initialize qiming workspace repository
This commit is contained in:
@@ -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());
|
||||
Reference in New Issue
Block a user