feat(coding): add PI-105 product file host API
This commit is contained in:
171
electron/api/coding-product-services.ts
Normal file
171
electron/api/coding-product-services.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type {
|
||||
CodingProjectFileContent,
|
||||
CodingProjectFileEntry,
|
||||
CodingTextSearchResult,
|
||||
ConversationChangesSnapshot,
|
||||
ProductCodingCommand,
|
||||
ProductCodingSkill,
|
||||
ProductPiCommandInput,
|
||||
} from '../../shared/coding-product-tools';
|
||||
import { createCodingConversationStore } from '../coding-projects/conversation-store';
|
||||
import type { CodingAttachmentStore } from '../coding-projects/attachment-store';
|
||||
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
|
||||
import { CodingProjectFileService } from '../coding-projects/project-files';
|
||||
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
|
||||
export interface ActiveCodingProject {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CodingProductHost {
|
||||
fileStatus(): Promise<CodingProjectFileEntry[]>;
|
||||
findFiles(query: string, limit?: number): Promise<CodingProjectFileEntry[]>;
|
||||
fileContent(path: string): Promise<CodingProjectFileContent>;
|
||||
searchText(pattern: string): Promise<CodingTextSearchResult[]>;
|
||||
listSkills(agentId?: string): Promise<ProductCodingSkill[]>;
|
||||
listCommands(conversationId: string): Promise<ProductCodingCommand[]>;
|
||||
getChanges(conversationId: string): Promise<ConversationChangesSnapshot | null>;
|
||||
}
|
||||
|
||||
export interface CodingProductComposition {
|
||||
attachments: CodingAttachmentStore;
|
||||
productTools: PiProductTools;
|
||||
host: CodingProductHost;
|
||||
}
|
||||
|
||||
export class CodingProductHostError extends Error {
|
||||
constructor(
|
||||
readonly status: 404 | 409,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CodingProductHostOptions {
|
||||
getActiveProject(): Promise<ActiveCodingProject | null>;
|
||||
productTools: PiProductTools;
|
||||
files?: CodingProjectFileService;
|
||||
listPiCommands?(conversationId: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
function normalizePiCommands(value: unknown): ProductPiCommandInput[] {
|
||||
const record = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
const candidates = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(record?.commands)
|
||||
? record.commands
|
||||
: Array.isArray(record?.data)
|
||||
? record.data
|
||||
: [];
|
||||
return candidates.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
|
||||
const command = candidate as Record<string, unknown>;
|
||||
if (typeof command.name !== 'string') return [];
|
||||
return [{
|
||||
name: command.name,
|
||||
...(typeof command.description === 'string' ? { description: command.description } : {}),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function createCodingProductHost(options: CodingProductHostOptions): CodingProductHost {
|
||||
const files = options.files ?? new CodingProjectFileService();
|
||||
|
||||
async function activeProject(): Promise<ActiveCodingProject> {
|
||||
const project = await options.getActiveProject();
|
||||
if (!project) {
|
||||
throw new CodingProductHostError(
|
||||
409,
|
||||
'CODING_ACTIVE_PROJECT_REQUIRED',
|
||||
'No active coding project is selected',
|
||||
);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function selectedSkillIds(
|
||||
projectPath: string,
|
||||
agentId?: string,
|
||||
): Promise<readonly string[]> {
|
||||
if (!agentId) return [];
|
||||
const config = await readCodingProjectConfigV2(projectPath);
|
||||
if (config.status !== 'valid') {
|
||||
throw new CodingProductHostError(
|
||||
409,
|
||||
'CODING_PROJECT_CONFIG_INVALID',
|
||||
'Coding project configuration is unavailable',
|
||||
);
|
||||
}
|
||||
const agent = config.config.agents.find((candidate) => (
|
||||
candidate.id === agentId && candidate.enabled && !candidate.archivedAt
|
||||
));
|
||||
if (!agent) {
|
||||
throw new CodingProductHostError(
|
||||
404,
|
||||
'CODING_AGENT_NOT_FOUND',
|
||||
'Coding project Agent does not exist',
|
||||
);
|
||||
}
|
||||
return agent.skillIds;
|
||||
}
|
||||
|
||||
async function conversationContext(conversationId: string): Promise<{
|
||||
project: ActiveCodingProject;
|
||||
skillIds: readonly string[];
|
||||
}> {
|
||||
const project = await activeProject();
|
||||
const conversation = await createCodingConversationStore(project.path).get(conversationId);
|
||||
if (!conversation) {
|
||||
throw new CodingProductHostError(
|
||||
404,
|
||||
'CODING_CONVERSATION_NOT_FOUND',
|
||||
'Coding Conversation does not exist',
|
||||
);
|
||||
}
|
||||
return {
|
||||
project,
|
||||
skillIds: await selectedSkillIds(project.path, conversation.agentId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async fileStatus() {
|
||||
const project = await activeProject();
|
||||
return await files.status(project.path);
|
||||
},
|
||||
async findFiles(query, limit) {
|
||||
const project = await activeProject();
|
||||
return await files.find(project.path, query, limit);
|
||||
},
|
||||
async fileContent(filePath) {
|
||||
const project = await activeProject();
|
||||
return await files.content(project.path, filePath);
|
||||
},
|
||||
async searchText(pattern) {
|
||||
const project = await activeProject();
|
||||
return await files.search(project.path, pattern);
|
||||
},
|
||||
async listSkills(agentId) {
|
||||
const project = await activeProject();
|
||||
return await options.productTools.listSkills(
|
||||
await selectedSkillIds(project.path, agentId),
|
||||
);
|
||||
},
|
||||
async listCommands(conversationId) {
|
||||
const context = await conversationContext(conversationId);
|
||||
const piCommands = options.listPiCommands
|
||||
? normalizePiCommands(await options.listPiCommands(conversationId))
|
||||
: [];
|
||||
return await options.productTools.listCommands(context.skillIds, piCommands);
|
||||
},
|
||||
async getChanges(conversationId) {
|
||||
await conversationContext(conversationId);
|
||||
return options.productTools.getChanges(conversationId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import type { StaticArtifactSnapshot } from '../services/static-release-server';
|
||||
import type { BackgroundLifecycleController } from '../main/background-lifecycle';
|
||||
import type { ReleaseJobManager } from '../services/release-job';
|
||||
import type { CodingProductComposition } from './coding-product-services';
|
||||
|
||||
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
|
||||
|
||||
@@ -77,4 +78,5 @@ export interface HostApiContext {
|
||||
imageWorkspace?: DesignWorkspaceModule;
|
||||
lifecycle?: BackgroundLifecycleController;
|
||||
releaseJobs?: ReleaseJobManager;
|
||||
codingProducts?: CodingProductComposition;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { handleCodingFileRoutes } from './routes/coding-files';
|
||||
|
||||
export type HostApiRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
@@ -42,6 +43,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
handleCodingFileRoutes,
|
||||
handleOpencodeRoutes,
|
||||
handleSettingsRoutes,
|
||||
handleProviderRoutes,
|
||||
|
||||
138
electron/api/routes/coding-files.ts
Normal file
138
electron/api/routes/coding-files.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { CodingProductHostError } from '../coding-product-services';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
function unavailable(res: ServerResponse): void {
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE',
|
||||
error: 'Coding product tools are unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
function serviceError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof CodingProductHostError) {
|
||||
sendJson(res, error.status, {
|
||||
success: false,
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const code = error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: '';
|
||||
if (code === 'ENOENT') {
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
code: 'CODING_FILE_NOT_FOUND',
|
||||
error: 'Project file does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
const knownInputError = new Set([
|
||||
'File query is required',
|
||||
'File query is too long',
|
||||
'Search pattern is required',
|
||||
'Search pattern is too long',
|
||||
'Project file path must be relative',
|
||||
'Project file path escapes the active project',
|
||||
'Project file path is not a file',
|
||||
'Binary project files cannot be previewed',
|
||||
'Project file is not valid UTF-8 text',
|
||||
]);
|
||||
if (knownInputError.has(message)) {
|
||||
sendJson(res, 400, {
|
||||
success: false,
|
||||
code: 'CODING_FILE_REQUEST_INVALID',
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, {
|
||||
success: false,
|
||||
code: 'CODING_PRODUCT_TOOL_FAILED',
|
||||
error: 'Coding product request failed',
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCodingFileRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const host = ctx.codingProducts?.host;
|
||||
const fixedGetRoutes = new Set([
|
||||
'/api/coding/files/status',
|
||||
'/api/coding/files/find',
|
||||
'/api/coding/files/content',
|
||||
'/api/coding/search',
|
||||
'/api/coding/skills',
|
||||
]);
|
||||
const commandMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/commands$/);
|
||||
const changesMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/changes$/);
|
||||
if (!fixedGetRoutes.has(url.pathname) && !commandMatch && !changesMatch) return false;
|
||||
if (req.method !== 'GET') return false;
|
||||
if (!host) {
|
||||
unavailable(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (url.pathname === '/api/coding/files/status') {
|
||||
sendJson(res, 200, { files: await host.fileStatus() });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/files/find') {
|
||||
const query = url.searchParams.get('query')?.trim() ?? '';
|
||||
const rawLimit = url.searchParams.get('limit');
|
||||
const parsedLimit = rawLimit ? Number(rawLimit) : undefined;
|
||||
sendJson(res, 200, {
|
||||
files: await host.findFiles(
|
||||
query,
|
||||
parsedLimit !== undefined && Number.isSafeInteger(parsedLimit) && parsedLimit > 0
|
||||
? parsedLimit
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/files/content') {
|
||||
sendJson(res, 200, {
|
||||
file: await host.fileContent(url.searchParams.get('path')?.trim() ?? ''),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/search') {
|
||||
sendJson(res, 200, {
|
||||
matches: await host.searchText(url.searchParams.get('pattern')?.trim() ?? ''),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/skills') {
|
||||
sendJson(res, 200, {
|
||||
skills: await host.listSkills(url.searchParams.get('agentId')?.trim() || undefined),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (commandMatch) {
|
||||
sendJson(res, 200, {
|
||||
commands: await host.listCommands(decodeURIComponent(commandMatch[1])),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (changesMatch) {
|
||||
sendJson(res, 200, {
|
||||
changes: await host.getChanges(decodeURIComponent(changesMatch[1])),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
serviceError(res, error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user