完善客户端模块与工作区能力
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-13 19:45:30 +08:00
parent 0148b91a15
commit 22add3f01f
97 changed files with 4756 additions and 3226 deletions

View File

@@ -777,6 +777,49 @@ function isPermissionReply(input: unknown): input is OpencodePermissionReply {
return input === 'once' || input === 'always' || input === 'reject';
}
function getPermissionRequestId(input: unknown): string | null {
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
const record = input as Record<string, unknown>;
for (const key of ['id', 'requestID', 'requestId']) {
const value = record[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return getPermissionRequestId(record.request)
?? getPermissionRequestId(record.permission)
?? getPermissionRequestId(record.properties)
?? getPermissionRequestId(record.data);
}
async function autoApprovePermission(
client: ActiveProjectClient,
permission: unknown,
): Promise<boolean> {
const requestID = getPermissionRequestId(permission);
if (!requestID) return false;
try {
await client.replyPermission(requestID, 'always');
logger.info('[opencode-permission] Auto-approved permission', { requestID });
return true;
} catch (error) {
logger.warn('[opencode-permission] Failed to recover pending permission with auto-approval', {
requestID,
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
async function autoApprovePendingPermissions(
client: ActiveProjectClient,
permissions: unknown[],
): Promise<unknown[]> {
const results = await Promise.all(permissions.map(async (permission) => (
await autoApprovePermission(client, permission) ? null : permission
)));
return results.filter((permission): permission is unknown => permission !== null);
}
function normalizeRevertPayload(input: Record<string, unknown>): RevertOpencodeSessionMessageInput | null {
const messageID = typeof input.messageID === 'string' && input.messageID.trim()
? input.messageID.trim()
@@ -873,6 +916,10 @@ export async function handleOpencodeRoutes(
path: '/event',
directory: context.activeProject.path,
});
const client = createOpencodeClient({
baseUrl: context.status.url!,
directory: context.activeProject.path,
});
const upstream = await globalThis.fetch(decorated.url, {
method: 'GET',
headers: {
@@ -911,6 +958,13 @@ export async function handleOpencodeRoutes(
const parsed = parseSseFrame(frameText);
const normalized = parsed ? normalizeOpencodeEventFrame(parsed, sessionFilter) : null;
if (normalized) {
if (normalized.type === 'permission.asked') {
const autoApproved = await autoApprovePermission(client, normalized.payload);
if (autoApproved) {
boundary = buffer.indexOf('\n\n');
continue;
}
}
if (normalized.type === 'session.error') {
const summary = summarizeOpencodeSessionError(normalized.payload);
logger.warn('[opencode-events] session.error from runtime', summary ?? {});
@@ -1424,7 +1478,8 @@ export async function handleOpencodeRoutes(
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const permissions = await client.listPermissions();
sendJson(res, 200, { permissions });
const pendingPermissions = await autoApprovePendingPermissions(client, permissions);
sendJson(res, 200, { permissions: pendingPermissions });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}

View File

@@ -42,6 +42,14 @@ type SpeechTranscriptionInput = {
type ImageGenerationInput = Record<string, unknown>;
type AgentProfileUpdateInput = Record<string, unknown>;
type AgentAvatarUploadInput = {
fileName?: unknown;
mimeType?: unknown;
dataBase64?: unknown;
};
const MAX_AGENT_AVATAR_BYTES = 4 * 1024 * 1024;
const AGENT_AVATAR_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
@@ -93,7 +101,21 @@ function getErrorMessage(payload: unknown, fallback: string): string {
for (const field of ['msg', 'message', 'error_description', 'error', 'detail']) {
const value = record[field];
if (typeof value === 'string' && value.trim()) {
return value;
return value.trim();
}
}
// FastAPI and similar services may put the actual message inside a
// structured `detail` object. Preserve it so the renderer can tell a
// service outage from a missing migration or another actionable error.
const detail = record.detail;
if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
const detailRecord = detail as Record<string, unknown>;
for (const field of ['msg', 'message', 'error_description', 'error']) {
const value = detailRecord[field];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
}
}
@@ -103,6 +125,18 @@ function getErrorMessage(payload: unknown, fallback: string): string {
return fallback;
}
function getPayloadErrorCode(payload: unknown): string | undefined {
if (!isRecord(payload)) return undefined;
const detail = isRecord(payload.detail) ? payload.detail : undefined;
return readOptionalString(payload.code) ?? (detail ? readOptionalString(detail.code) : undefined);
}
function stripReadOnlyAgentProfileFields(body: AgentProfileUpdateInput): AgentProfileUpdateInput {
const writableFields = { ...body };
delete writableFields.avatar_url;
return writableFields;
}
async function sendUpstreamError(
res: ServerResponse,
response: Response,
@@ -574,7 +608,7 @@ async function handleAgentProfile(
): Promise<void> {
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
const body = req.method === 'PUT'
? await parseJsonBody<AgentProfileUpdateInput>(req)
? stripReadOnlyAgentProfileFields(await parseJsonBody<AgentProfileUpdateInput>(req))
: undefined;
const response = await proxyAwareFetch(createWorksUrl('/api/user/agent-profile').toString(), {
method: req.method,
@@ -590,13 +624,22 @@ async function handleAgentProfile(
const detail = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>).detail
: undefined;
const code = getPayloadErrorCode(payload);
const error = getErrorMessage(payload, `Agent Profile request failed (${response.status})`);
logger.warn('[works] Agent Profile upstream request failed', {
method: req.method,
status: response.status,
...(code === undefined ? {} : { code }),
error,
});
// Keep the local Host API response successful so the renderer can inspect
// the upstream status and conflict detail instead of losing it in the
// generic Host API error parser.
sendJson(res, 200, {
success: false,
status: response.status,
error: getErrorMessage(payload, `Agent Profile request failed (${response.status})`),
error,
...(code === undefined ? {} : { code }),
...(detail === undefined ? {} : { detail }),
});
return;
@@ -605,6 +648,98 @@ async function handleAgentProfile(
sendJson(res, response.status, { success: true, profile: payload });
}
function createAgentAvatarUploadForm(body: AgentAvatarUploadInput): FormData {
const fileName = (readOptionalString(body.fileName) ?? 'avatar.webp')
.replace(/[\\/]/g, '_')
.slice(0, 160);
const mimeType = readRequiredString(body.mimeType, 'mimeType').toLowerCase();
if (!AGENT_AVATAR_MIME_TYPES.has(mimeType)) {
throw new Error('Unsupported avatar image type');
}
const dataBase64 = readRequiredString(body.dataBase64, 'dataBase64');
if (!/^[A-Za-z0-9+/]*={0,2}$/u.test(dataBase64) || dataBase64.length % 4 === 1) {
throw new Error('Invalid avatar image data');
}
const bytes = Buffer.from(dataBase64, 'base64');
if (bytes.length === 0) throw new Error('Avatar image data is empty');
if (bytes.length > MAX_AGENT_AVATAR_BYTES) {
throw new Error('Avatar image is too large');
}
const form = new FormData();
form.set('file', new Blob([new Uint8Array(bytes)], { type: mimeType }), fileName);
return form;
}
function normalizeAgentAvatarPayload(payload: unknown): {
success: true;
avatar_url: string | null;
} {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return { success: true, avatar_url: null };
}
const record = payload as Record<string, unknown>;
const profile = record.profile && typeof record.profile === 'object' && !Array.isArray(record.profile)
? record.profile as Record<string, unknown>
: undefined;
const data = record.data && typeof record.data === 'object' && !Array.isArray(record.data)
? record.data as Record<string, unknown>
: undefined;
const value = record.avatar_url ?? profile?.avatar_url ?? data?.avatar_url;
return {
success: true,
avatar_url: typeof value === 'string' && value.trim() ? value.trim() : null,
};
}
async function handleAgentAvatar(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
const form = req.method === 'POST'
? createAgentAvatarUploadForm(await parseJsonBody<AgentAvatarUploadInput>(req))
: undefined;
const response = await proxyAwareFetch(createWorksUrl('/api/user/avatar').toString(), {
method: req.method,
headers: {
Authorization: `Bearer ${accessToken}`,
},
...(form ? { body: form } : {}),
});
const payload = await readResponsePayload(response);
if (!response.ok) {
const detail = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>).detail
: undefined;
const code = getPayloadErrorCode(payload);
const error = getErrorMessage(payload, `Agent Avatar request failed (${response.status})`);
logger.warn('[works] Agent Avatar upstream request failed', {
method: req.method,
status: response.status,
...(code === undefined ? {} : { code }),
error,
});
sendJson(res, 200, {
success: false,
status: response.status,
error,
...(code === undefined ? {} : { code }),
...(detail === undefined ? {} : { detail }),
});
return;
}
sendJson(
res,
response.status === 204 ? 200 : response.status,
normalizeAgentAvatarPayload(payload),
);
}
async function handleSubmitImageGeneration(
req: IncomingMessage,
res: ServerResponse,
@@ -1054,6 +1189,11 @@ export async function handleWorksRoutes(
}
try {
if (url.pathname === '/api/works/user/avatar' && (req.method === 'POST' || req.method === 'DELETE')) {
await handleAgentAvatar(req, res);
return true;
}
if (url.pathname === '/api/works/user/agent-profile' && (req.method === 'GET' || req.method === 'PUT')) {
await handleAgentProfile(req, res);
return true;