feat(coding): add PI-105 product file host API

This commit is contained in:
2026-08-23 18:17:30 +08:00
parent 7680bfb0cf
commit 9520872d2f
17 changed files with 1419 additions and 38 deletions

View 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);
},
};
}

View File

@@ -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;
}

View File

@@ -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,

View 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;
}