Files
qiming/qimingclaw/crates/agent-electron-client/src/renderer/services/integrations/fileServer.ts

847 lines
23 KiB
TypeScript
Raw Blame History

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