完善客户端模块与工作区能力
This commit is contained in:
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
DesignCreateWorkspaceInput,
|
||||
DesignGenerationQuote,
|
||||
DesignGenerationTask,
|
||||
DesignMessage,
|
||||
DesignMedium,
|
||||
DesignRenameWorkspaceInput,
|
||||
DesignSubmitMessageInput,
|
||||
@@ -138,6 +139,14 @@ function messageHash(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function latestMessagePreview(messages: DesignMessage[]): string | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const preview = messages[index]?.text.replace(/\s+/gu, ' ').trim();
|
||||
if (preview) return preview;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectMedium(message: string): DesignMedium {
|
||||
return /视频|动画|动效|镜头|转场/.test(message) ? 'video' : 'image';
|
||||
}
|
||||
@@ -776,7 +785,10 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
|
||||
messages: _messages,
|
||||
...summary
|
||||
} = conversation;
|
||||
return clone(summary);
|
||||
return clone({
|
||||
...summary,
|
||||
latestMessagePreview: latestMessagePreview(conversation.messages),
|
||||
});
|
||||
}
|
||||
|
||||
private conversationView(conversation: PersistedConversation): DesignConversation {
|
||||
@@ -785,6 +797,9 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
|
||||
requestHashes: _requestHashes,
|
||||
...view
|
||||
} = conversation;
|
||||
return clone(view);
|
||||
return clone({
|
||||
...view,
|
||||
latestMessagePreview: latestMessagePreview(conversation.messages),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ type ServerConversationSummary = {
|
||||
workspace_id: string;
|
||||
agent_session_id: string | null;
|
||||
title: string;
|
||||
latest_message_preview?: string | null;
|
||||
turn_revision: number;
|
||||
phase: DesignConversationSummary['phase'];
|
||||
brief: ServerBrief;
|
||||
@@ -270,6 +271,19 @@ function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessagePreview(value: string | null | undefined): string | null {
|
||||
const preview = value?.replace(/\s+/gu, ' ').trim();
|
||||
return preview || null;
|
||||
}
|
||||
|
||||
function latestServerMessagePreview(messages: ServerMessage[]): string | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const preview = normalizeMessagePreview(messages[index]?.text);
|
||||
if (preview) return preview;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapConversationSummary(
|
||||
conversation: ServerConversationSummary,
|
||||
): DesignConversationSummary {
|
||||
@@ -277,6 +291,9 @@ function mapConversationSummary(
|
||||
conversationId: conversation.conversation_id,
|
||||
workspaceId: conversation.workspace_id,
|
||||
title: conversation.title,
|
||||
latestMessagePreview: normalizeMessagePreview(
|
||||
conversation.latest_message_preview,
|
||||
) ?? normalizeMessagePreview(conversation.brief.summary),
|
||||
turnRevision: conversation.turn_revision,
|
||||
phase: conversation.phase,
|
||||
brief: mapBrief(conversation.brief),
|
||||
@@ -286,18 +303,22 @@ function mapConversationSummary(
|
||||
}
|
||||
|
||||
function mapConversation(conversation: ServerConversation): DesignConversation {
|
||||
const messages = conversation.messages.map((message, index) => ({
|
||||
id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`,
|
||||
role: message.role,
|
||||
kind: message.kind,
|
||||
text: message.text,
|
||||
quickReplies: message.quick_replies,
|
||||
generationQuote: mapQuote(message.generation_quote),
|
||||
turnRevision: message.turn_revision,
|
||||
createdAt: message.created_at,
|
||||
}));
|
||||
return {
|
||||
...mapConversationSummary(conversation),
|
||||
messages: conversation.messages.map((message, index) => ({
|
||||
id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`,
|
||||
role: message.role,
|
||||
kind: message.kind,
|
||||
text: message.text,
|
||||
quickReplies: message.quick_replies,
|
||||
generationQuote: mapQuote(message.generation_quote),
|
||||
turnRevision: message.turn_revision,
|
||||
createdAt: message.created_at,
|
||||
})),
|
||||
latestMessagePreview: latestServerMessagePreview(messages)
|
||||
?? normalizeMessagePreview(conversation.latest_message_preview)
|
||||
?? normalizeMessagePreview(conversation.brief.summary),
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
29
electron/main/admin-access.ts
Normal file
29
electron/main/admin-access.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const ADMIN_PASSWORD = 'zhiniankeji666';
|
||||
|
||||
let adminSessionUnlocked = false;
|
||||
|
||||
function matchesAdminPassword(password: unknown): boolean {
|
||||
if (typeof password !== 'string') return false;
|
||||
|
||||
const received = Buffer.from(password, 'utf8');
|
||||
const expected = Buffer.from(ADMIN_PASSWORD, 'utf8');
|
||||
return received.length === expected.length && timingSafeEqual(received, expected);
|
||||
}
|
||||
|
||||
export function verifyAdminPassword(password: unknown): boolean {
|
||||
const valid = matchesAdminPassword(password);
|
||||
if (valid) {
|
||||
adminSessionUnlocked = true;
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
export function isAdminSessionUnlocked(): boolean {
|
||||
return adminSessionUnlocked;
|
||||
}
|
||||
|
||||
export function lockAdminSession(): void {
|
||||
adminSessionUnlocked = false;
|
||||
}
|
||||
@@ -258,7 +258,11 @@ function createWindow(): BrowserWindow {
|
||||
sandbox: false,
|
||||
webviewTag: false,
|
||||
},
|
||||
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
|
||||
// `hiddenInset` leaves a native top inset in the renderer viewport. That
|
||||
// makes a full-height Canvas look like it has an extra horizontal bar and
|
||||
// clips the top of the columns. `hidden` keeps the traffic lights while
|
||||
// giving the renderer the full window bounds.
|
||||
titleBarStyle: isMac ? 'hidden' : useCustomTitleBar ? 'hidden' : 'default',
|
||||
// Keep the native traffic lights on the same centerline as the 40px
|
||||
// renderer title bar. The native glyphs sit about 7px below the
|
||||
// configured origin, so y=13 centers them on the renderer controls.
|
||||
|
||||
@@ -13,6 +13,11 @@ import { getProviderService } from '../services/providers/provider-service';
|
||||
import type { ProviderConfig } from '../utils/secure-storage';
|
||||
import type { ProviderAccount } from '../shared/providers/types';
|
||||
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
|
||||
import {
|
||||
isAdminSessionUnlocked,
|
||||
lockAdminSession,
|
||||
verifyAdminPassword,
|
||||
} from './admin-access';
|
||||
|
||||
type UnifiedRequest = {
|
||||
id?: string;
|
||||
@@ -282,6 +287,15 @@ export function registerIpcHandlers(
|
||||
return { success: true, settings: await getAllSettings() };
|
||||
});
|
||||
|
||||
ipcMain.handle('admin:verifyPassword', (_event, password: unknown) => ({
|
||||
success: verifyAdminPassword(password),
|
||||
}));
|
||||
ipcMain.handle('admin:isUnlocked', () => isAdminSessionUnlocked());
|
||||
ipcMain.handle('admin:lock', () => {
|
||||
lockAdminSession();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('shell:openExternal', (_event, url: string) => shell.openExternal(url));
|
||||
ipcMain.handle('shell:showItemInFolder', (_event, path: string) => shell.showItemInFolder(path));
|
||||
ipcMain.handle('shell:openPath', (_event, path: string) => shell.openPath(path));
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface EnsureBundledCourseSkillsOptions {
|
||||
|
||||
export const BUNDLED_COURSE_SKILL_IDS = [
|
||||
'agent-browser',
|
||||
] as const;
|
||||
const RETIRED_COURSE_SKILL_IDS = [
|
||||
'course-stage-review',
|
||||
'student-growth-logger',
|
||||
'ai-video-creation-skill',
|
||||
'deploy-publish-check',
|
||||
'designer-design-spec',
|
||||
'dev-build-test',
|
||||
'game-assets',
|
||||
@@ -30,13 +36,8 @@ export const BUNDLED_COURSE_SKILL_IDS = [
|
||||
'pm-project-plan',
|
||||
'product-demo-prototype',
|
||||
'ui-ux-course-quality',
|
||||
'youth-plain-language',
|
||||
'youth-ai-product-course',
|
||||
] as const;
|
||||
const RETIRED_COURSE_SKILL_IDS = [
|
||||
'course-stage-review',
|
||||
'deploy-publish-check',
|
||||
'student-growth-logger',
|
||||
'youth-plain-language',
|
||||
] as const;
|
||||
const LEGACY_SUPERPOWERS_ARTIFACTS = [
|
||||
'plugins/superpowers-niancode.js',
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
createProjectConfig,
|
||||
isProjectAgentAvatarDataUrl,
|
||||
isProjectType,
|
||||
validateAgentConfigs,
|
||||
validateAgentNames,
|
||||
@@ -61,6 +62,7 @@ function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||||
avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId)
|
||||
? raw.avatarId
|
||||
: 'avatar-01',
|
||||
avatarDataUrl: isProjectAgentAvatarDataUrl(raw.avatarDataUrl) ? raw.avatarDataUrl : undefined,
|
||||
roleName: typeof raw.roleName === 'string' && raw.roleName.trim() ? raw.roleName.trim() : '项目伙伴',
|
||||
name,
|
||||
// Preserve this legacy marker when reading an existing project. New
|
||||
@@ -133,8 +135,10 @@ export async function readProjectConfig(projectPath: string): Promise<ProjectCon
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
|
||||
const config = normalizeProjectConfig(raw);
|
||||
if (!(await areMaterializedAgentsCurrent(projectPath, config))) {
|
||||
await materializeAgents(projectPath, config);
|
||||
}
|
||||
if (needsLegacyAgentModelMigration(raw)) {
|
||||
if (config.initialized) await materializeAgents(projectPath, config);
|
||||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
return { status: 'valid', config };
|
||||
@@ -187,9 +191,12 @@ export function buildProjectAgentPrompt(config: ProjectConfig, current: ProjectA
|
||||
}
|
||||
|
||||
function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string {
|
||||
const skills = agent.skillIds.length > 0
|
||||
? agent.skillIds.map((skill) => ` ${skill}: allow`).join('\n')
|
||||
: ' "*": deny';
|
||||
const skills = [
|
||||
' "*": deny',
|
||||
...agent.skillIds
|
||||
.filter((skill) => skill !== '*')
|
||||
.map((skill) => ` ${skill}: allow`),
|
||||
].join('\n');
|
||||
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
|
||||
const model = agent.model;
|
||||
const prompt = agent.prompt.trim() || buildProjectAgentPrompt(config, agent);
|
||||
@@ -205,6 +212,21 @@ ${skills}
|
||||
${prompt}`;
|
||||
}
|
||||
|
||||
async function areMaterializedAgentsCurrent(projectPath: string, config: ProjectConfig): Promise<boolean> {
|
||||
if (!config.initialized || config.agents.length === 0) return true;
|
||||
|
||||
const results = await Promise.all(config.agents.map(async (agent) => {
|
||||
try {
|
||||
const existing = await readFile(path.join(projectPath, '.opencode', 'agent', `${agent.id}.md`), 'utf8');
|
||||
return existing === buildAgentMarkdown(config, agent);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
return results.every(Boolean);
|
||||
}
|
||||
|
||||
async function materializeAgents(projectPath: string, config: ProjectConfig): Promise<void> {
|
||||
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
|
||||
await mkdir(agentDirectory, { recursive: true });
|
||||
|
||||
@@ -66,9 +66,7 @@ export interface OpencodeProviderModelEntry {
|
||||
export interface OpencodeRuntimeConfig {
|
||||
'$schema': string;
|
||||
enabled_providers: string[];
|
||||
permission: {
|
||||
external_directory: 'ask';
|
||||
};
|
||||
permission: 'allow';
|
||||
provider: Record<string, OpencodeProviderEntry>;
|
||||
mcp?: Record<string, OpencodeMcpServerEntry>;
|
||||
model?: string;
|
||||
@@ -283,9 +281,7 @@ export async function buildOpencodeRuntimeConfig(
|
||||
const config: OpencodeRuntimeConfig = {
|
||||
'$schema': OPENCODE_CONFIG_SCHEMA,
|
||||
enabled_providers: [],
|
||||
permission: {
|
||||
external_directory: 'ask',
|
||||
},
|
||||
permission: 'allow',
|
||||
provider: {},
|
||||
};
|
||||
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
|
||||
|
||||
@@ -41,6 +41,9 @@ const validInvokeChannels = [
|
||||
'settings:setMany',
|
||||
'settings:getAll',
|
||||
'settings:reset',
|
||||
'admin:verifyPassword',
|
||||
'admin:isUnlocked',
|
||||
'admin:lock',
|
||||
'provider:list',
|
||||
'provider:get',
|
||||
'provider:save',
|
||||
|
||||
@@ -40,7 +40,6 @@ export interface AppSettings {
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: boolean;
|
||||
devModeUnlocked: boolean;
|
||||
|
||||
// Presets
|
||||
selectedBundles: string[];
|
||||
@@ -88,7 +87,6 @@ function createDefaultSettings(): AppSettings {
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: false,
|
||||
devModeUnlocked: false,
|
||||
|
||||
// Presets
|
||||
selectedBundles: ['productivity', 'developer'],
|
||||
@@ -107,6 +105,10 @@ async function getSettingsStore() {
|
||||
name: 'settings',
|
||||
defaults: createDefaultSettings(),
|
||||
});
|
||||
// The developer-mode toggle used to be persisted as a renderer setting.
|
||||
// Remove that legacy value so it cannot be mistaken for an active admin
|
||||
// session after upgrading an existing installation.
|
||||
settingsStoreInstance.delete('devModeUnlocked');
|
||||
}
|
||||
return settingsStoreInstance;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user