mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Compare commits
21 commits
6c52d6a9f0
...
0c3dff0231
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3dff0231 | ||
|
|
67396350c8 | ||
|
|
755d8173df | ||
|
|
0bffe6ba40 | ||
|
|
f2e04df500 | ||
|
|
328d893b4f | ||
|
|
646fa370d2 | ||
|
|
393bfd0c6e | ||
|
|
de6c0d498e | ||
|
|
a5f09334a2 | ||
|
|
34ecd800ae | ||
|
|
871a393917 | ||
|
|
af4a62b69a | ||
|
|
3395d2c855 | ||
|
|
333a62cda6 | ||
|
|
def2e9fd2e | ||
|
|
729ce2b0cb | ||
|
|
e8c179d3a6 | ||
|
|
0b493a66c0 | ||
|
|
8448b1105c | ||
|
|
d66a463d62 |
122 changed files with 6693 additions and 2499 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -76,4 +76,7 @@ docs/superpowers/
|
||||||
# on every deploy. Should not be committed (~70MB of PNGs).
|
# on every deploy. Should not be committed (~70MB of PNGs).
|
||||||
apps/landing-page/public/previews/
|
apps/landing-page/public/previews/
|
||||||
|
|
||||||
growth/**
|
# Ad-hoc local e2e scripts and their screenshots
|
||||||
|
e2e/scripts/test-fal-webui.ts
|
||||||
|
e2e/scripts/fal-webui-*.png
|
||||||
|
growth/**
|
||||||
|
|
|
||||||
16
apps/daemon/bin/od.mjs
Executable file
16
apps/daemon/bin/od.mjs
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const entryDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const distEntry = resolve(entryDir, "../dist/cli.js");
|
||||||
|
|
||||||
|
if (!existsSync(distEntry)) {
|
||||||
|
throw new Error(
|
||||||
|
`Open Design daemon dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/daemon build" first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await import(pathToFileURL(distEntry).href);
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"main": "./dist/cli.js",
|
"main": "./dist/cli.js",
|
||||||
"types": "./dist/cli.d.ts",
|
"types": "./dist/cli.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"od": "./dist/cli.js"
|
"od": "./bin/od.mjs"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"bin",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@
|
||||||
// outside this machine.
|
// outside this machine.
|
||||||
|
|
||||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { expandHomePrefix } from './home-expansion.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
readInstallationFile,
|
readInstallationFile,
|
||||||
|
|
@ -85,6 +86,12 @@ export interface OrbitConfigPrefs {
|
||||||
templateSkillId?: string | null;
|
templateSkillId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectLocationPrefs {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfigPrefs {
|
export interface AppConfigPrefs {
|
||||||
onboardingCompleted?: boolean;
|
onboardingCompleted?: boolean;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
|
|
@ -99,6 +106,8 @@ export interface AppConfigPrefs {
|
||||||
privacyDecisionAt?: number | null;
|
privacyDecisionAt?: number | null;
|
||||||
orbit?: OrbitConfigPrefs;
|
orbit?: OrbitConfigPrefs;
|
||||||
customInstructions?: string | null;
|
customInstructions?: string | null;
|
||||||
|
projectLocations?: ProjectLocationPrefs[];
|
||||||
|
defaultProjectLocationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
||||||
|
|
@ -115,6 +124,8 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
||||||
'privacyDecisionAt',
|
'privacyDecisionAt',
|
||||||
'orbit',
|
'orbit',
|
||||||
'customInstructions',
|
'customInstructions',
|
||||||
|
'projectLocations',
|
||||||
|
'defaultProjectLocationId',
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
function configFile(dataDir: string): string {
|
function configFile(dataDir: string): string {
|
||||||
|
|
@ -245,6 +256,46 @@ function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined {
|
||||||
return orbit;
|
return orbit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLocationId(raw: string, fallback: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (/^[A-Za-z0-9._-]{1,128}$/.test(trimmed) && trimmed !== 'default') {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoProjectLocationId(pathKey: string): string {
|
||||||
|
return `loc_${createHash('sha256').update(pathKey).digest('base64url').slice(0, 16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProjectLocations(raw: unknown): ProjectLocationPrefs[] | undefined {
|
||||||
|
if (raw === undefined || raw === null) return undefined;
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const result: ProjectLocationPrefs[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenPaths = new Set<string>();
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
if (typeof obj.path !== 'string') continue;
|
||||||
|
const expanded = expandHomePrefix(obj.path.trim());
|
||||||
|
if (!expanded || !path.isAbsolute(expanded)) continue;
|
||||||
|
const normalizedPath = path.normalize(expanded);
|
||||||
|
const pathKey = process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath;
|
||||||
|
if (seenPaths.has(pathKey)) continue;
|
||||||
|
const id = normalizeLocationId(
|
||||||
|
typeof obj.id === 'string' ? obj.id : '',
|
||||||
|
autoProjectLocationId(pathKey),
|
||||||
|
);
|
||||||
|
if (seenIds.has(id)) continue;
|
||||||
|
const rawName = typeof obj.name === 'string' ? obj.name.trim() : '';
|
||||||
|
result.push({ id, name: rawName || path.basename(normalizedPath) || normalizedPath, path: normalizedPath });
|
||||||
|
seenIds.add(id);
|
||||||
|
seenPaths.add(pathKey);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function agentCliEnvForAgent(
|
export function agentCliEnvForAgent(
|
||||||
prefs: AgentCliEnvPrefs | undefined,
|
prefs: AgentCliEnvPrefs | undefined,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|
@ -330,6 +381,25 @@ function applyConfigValue(
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'projectLocations') {
|
||||||
|
const validated = validateProjectLocations(value);
|
||||||
|
if (validated !== undefined) {
|
||||||
|
target[key] = validated;
|
||||||
|
} else {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'defaultProjectLocationId') {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
target[key] = normalizeLocationId(value, 'default');
|
||||||
|
} else if (value === null) {
|
||||||
|
target[key] = null;
|
||||||
|
} else {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
|
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
|
||||||
|
|
|
||||||
|
|
@ -201,10 +201,15 @@ export function validateArtifactManifestInput(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeEntry = typeof entry === 'string' ? entry : '';
|
const manifestEntry =
|
||||||
if (!safeEntry || safeEntry.length > MAX_ENTRY_LENGTH) {
|
typeof manifest.entry === 'string' && manifest.entry.trim()
|
||||||
return { ok: false, error: `artifact entry exceeds max length (${MAX_ENTRY_LENGTH})` };
|
? manifest.entry.trim()
|
||||||
|
: entry;
|
||||||
|
const entryErr = validateSupportingPath(manifestEntry);
|
||||||
|
if (entryErr) {
|
||||||
|
return { ok: false, error: `artifactManifest.entry ${entryErr}` };
|
||||||
}
|
}
|
||||||
|
const safeEntry = (manifestEntry as string).replace(/\\/g, '/');
|
||||||
|
|
||||||
return { ok: true, value: sanitizeManifest(manifest, safeEntry, options) };
|
return { ok: true, value: sanitizeManifest(manifest, safeEntry, options) };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
|
||||||
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
||||||
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
|
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
|
||||||
import { googleStreamGenerateContentUrl } from './google-models.js';
|
import { googleStreamGenerateContentUrl } from './google-models.js';
|
||||||
import { parseMediaExecutionPolicyInput } from './media-policy.js';
|
|
||||||
import { createRoleMarkerGuard } from './role-marker-guard.js';
|
import { createRoleMarkerGuard } from './role-marker-guard.js';
|
||||||
|
|
||||||
// Allowlist for the `/feedback` route. Mirrors the
|
// Allowlist for the `/feedback` route. Mirrors the
|
||||||
|
|
@ -45,7 +44,7 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
|
||||||
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
const { db, design } = ctx;
|
const { db, design } = ctx;
|
||||||
const { sendApiError, createSseResponse } = ctx.http;
|
const { sendApiError, createSseResponse } = ctx.http;
|
||||||
const { startChatRun, submitToolResultToRun } = ctx.chat;
|
const { submitToolResultToRun } = ctx.chat;
|
||||||
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
|
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
|
||||||
const {
|
const {
|
||||||
handleCritiqueArtifact,
|
handleCritiqueArtifact,
|
||||||
|
|
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
critiqueResponseCapBytes,
|
critiqueResponseCapBytes,
|
||||||
critiqueRunRegistry,
|
critiqueRunRegistry,
|
||||||
} = ctx.critique;
|
} = ctx.critique;
|
||||||
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
|
|
||||||
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
|
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
|
||||||
if (
|
if (
|
||||||
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
|
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
|
||||||
|
|
@ -79,6 +77,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
// so any handler we wired here was shadowed and never executed. Plugin
|
// so any handler we wired here was shadowed and never executed. Plugin
|
||||||
// snapshot resolution, clientType inference, and the daemon-side
|
// snapshot resolution, clientType inference, and the daemon-side
|
||||||
// run_created/finished analytics all live in `server.ts` now.
|
// run_created/finished analytics all live in `server.ts` now.
|
||||||
|
// POST /api/chat is likewise owned by `server.ts`; keep the chat run
|
||||||
|
// launch path single-sourced so validation changes land on the live route.
|
||||||
|
|
||||||
app.get('/api/runs', (req, res) => {
|
app.get('/api/runs', (req, res) => {
|
||||||
const { projectId, conversationId, status } = req.query;
|
const { projectId, conversationId, status } = req.query;
|
||||||
|
|
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
res.status(202).json(outcome);
|
res.status(202).json(outcome);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/chat', (req, res) => {
|
|
||||||
if (isDaemonShuttingDown()) {
|
|
||||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
|
||||||
}
|
|
||||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
||||||
const mediaExecution = parseMediaExecutionPolicyInput(
|
|
||||||
(body as { mediaExecution?: unknown }).mediaExecution,
|
|
||||||
);
|
|
||||||
if (!mediaExecution.ok) {
|
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
|
||||||
}
|
|
||||||
const runBody = { ...body, mediaExecution: mediaExecution.policy };
|
|
||||||
const run = design.runs.create(runBody);
|
|
||||||
design.runs.stream(run, req, res);
|
|
||||||
design.runs.start(run, () => startChatRun(runBody, run));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
|
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
|
||||||
// Settings dialog uses these to verify a config works without sending a
|
// Settings dialog uses these to verify a config works without sending a
|
||||||
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused
|
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { redactSecrets } from './redact.js';
|
import { redactSecrets } from './redact.js';
|
||||||
|
|
||||||
export interface ClaudeCliDiagnosticInput {
|
export interface ClaudeCliDiagnosticInput {
|
||||||
|
|
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
|
||||||
stderrTail?: string | null;
|
stderrTail?: string | null;
|
||||||
stdoutTail?: string | null;
|
stdoutTail?: string | null;
|
||||||
env?: Record<string, unknown> | null;
|
env?: Record<string, unknown> | null;
|
||||||
|
resolvedBin?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaudeCliDiagnostic {
|
export interface ClaudeCliDiagnostic {
|
||||||
|
|
@ -51,6 +54,15 @@ function withContext(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
|
||||||
|
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
|
||||||
|
const base = path
|
||||||
|
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
|
||||||
|
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||||
|
.toLowerCase();
|
||||||
|
return base === 'openclaude' ? 'openclaude' : 'claude';
|
||||||
|
}
|
||||||
|
|
||||||
export function diagnoseClaudeCliFailure(
|
export function diagnoseClaudeCliFailure(
|
||||||
input: ClaudeCliDiagnosticInput,
|
input: ClaudeCliDiagnosticInput,
|
||||||
): ClaudeCliDiagnostic | null {
|
): ClaudeCliDiagnostic | null {
|
||||||
|
|
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
|
||||||
const normalized = text.toLowerCase();
|
const normalized = text.toLowerCase();
|
||||||
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
|
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
|
||||||
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
|
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
|
||||||
|
const runtime = selectedClaudeCompatibleRuntime(input);
|
||||||
|
const isOpenClaude = runtime === 'openclaude';
|
||||||
|
|
||||||
const customEndpointConnectionFailure =
|
const customEndpointConnectionFailure =
|
||||||
hasCustomBaseUrl &&
|
hasCustomBaseUrl &&
|
||||||
|
|
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (authFailure) {
|
if (authFailure) {
|
||||||
|
if (isOpenClaude) {
|
||||||
|
return withContext(
|
||||||
|
'OpenClaude could not authenticate with its configured endpoint.',
|
||||||
|
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
const configHint = hasConfigDir
|
const configHint = hasConfigDir
|
||||||
? 'The configured Claude config directory may contain stale or expired auth state.'
|
? 'The configured Claude config directory may contain stale or expired auth state.'
|
||||||
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
|
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
|
||||||
|
|
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!text.trim() && input.exitCode === 1) {
|
if (!text.trim() && input.exitCode === 1) {
|
||||||
|
if (isOpenClaude) {
|
||||||
|
return withContext(
|
||||||
|
'OpenClaude exited before producing diagnostics.',
|
||||||
|
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
const message = hasConfigDir
|
const message = hasConfigDir
|
||||||
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
|
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
|
||||||
: 'Claude Code exited before producing diagnostics.';
|
: 'Claude Code exited before producing diagnostics.';
|
||||||
|
|
|
||||||
|
|
@ -573,11 +573,11 @@ async function runMediaWait(rawArgs) {
|
||||||
const since = Number.isFinite(Number(flags.since))
|
const since = Number.isFinite(Number(flags.since))
|
||||||
? Number(flags.since)
|
? Number(flags.since)
|
||||||
: 0;
|
: 0;
|
||||||
await pollUntilDoneOrBudget(daemonUrl, taskId, since);
|
await pollUntilDoneOrBudget(daemonUrl, taskId, since, { totalBudgetMs: 120_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) {
|
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) {
|
||||||
const totalBudgetMs = 25_000;
|
const totalBudgetMs = typeof options.totalBudgetMs === 'number' ? options.totalBudgetMs : 25_000;
|
||||||
const perCallTimeoutMs = 4_000;
|
const perCallTimeoutMs = 4_000;
|
||||||
const stillRunningExitCode =
|
const stillRunningExitCode =
|
||||||
typeof options.stillRunningExitCode === 'number'
|
typeof options.stillRunningExitCode === 'number'
|
||||||
|
|
|
||||||
|
|
@ -1862,6 +1862,8 @@ async function testAgentConnectionInternal(
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredAgentEnv,
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: executableResolution.selectedPath },
|
||||||
);
|
);
|
||||||
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
|
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
|
||||||
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
|
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
|
||||||
|
|
@ -2026,6 +2028,7 @@ async function testAgentConnectionInternal(
|
||||||
stderrTail,
|
stderrTail,
|
||||||
stdoutTail: rawStdoutTail || buffered,
|
stdoutTail: rawStdoutTail || buffered,
|
||||||
env,
|
env,
|
||||||
|
resolvedBin: executableResolution.selectedPath,
|
||||||
});
|
});
|
||||||
if (claudeDiagnostic) {
|
if (claudeDiagnostic) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
|
||||||
|
|
@ -752,12 +752,23 @@ export function listConversations(db: SqliteDb, projectId: string) {
|
||||||
AND m.run_status IS NOT NULL
|
AND m.run_status IS NOT NULL
|
||||||
)
|
)
|
||||||
WHERE rn = 1
|
WHERE rn = 1
|
||||||
|
),
|
||||||
|
total_run_durations AS (
|
||||||
|
SELECT m.conversation_id AS conversationId,
|
||||||
|
SUM(${terminalRunDurationSql('m')}) AS totalDurationMs
|
||||||
|
FROM messages m
|
||||||
|
JOIN project_conversations c ON c.id = m.conversation_id
|
||||||
|
WHERE m.role = 'assistant'
|
||||||
|
AND m.run_status IN ('succeeded', 'failed', 'canceled')
|
||||||
|
GROUP BY m.conversation_id
|
||||||
)
|
)
|
||||||
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
|
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
|
||||||
lr.latestRunStatus, lr.latestRunStartedAt,
|
lr.latestRunStatus, lr.latestRunStartedAt,
|
||||||
lr.latestRunEndedAt, lr.latestRunEventsJson
|
lr.latestRunEndedAt, lr.latestRunEventsJson,
|
||||||
|
trd.totalDurationMs
|
||||||
FROM project_conversations c
|
FROM project_conversations c
|
||||||
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
|
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
|
||||||
|
LEFT JOIN total_run_durations trd ON trd.conversationId = c.id
|
||||||
ORDER BY c.updatedAt DESC`,
|
ORDER BY c.updatedAt DESC`,
|
||||||
)
|
)
|
||||||
.all(projectId)).map(normalizeConversation);
|
.all(projectId)).map(normalizeConversation);
|
||||||
|
|
@ -775,6 +786,7 @@ export function getConversation(db: SqliteDb, id: string) {
|
||||||
return {
|
return {
|
||||||
...normalizeConversation(r),
|
...normalizeConversation(r),
|
||||||
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
|
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
|
||||||
|
...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,10 +803,16 @@ function normalizeConversation(r: DbRow) {
|
||||||
title: r.title ?? null,
|
title: r.title ?? null,
|
||||||
createdAt: Number(r.createdAt),
|
createdAt: Number(r.createdAt),
|
||||||
updatedAt: Number(r.updatedAt),
|
updatedAt: Number(r.updatedAt),
|
||||||
|
...numberProperty('totalDurationMs', r.totalDurationMs),
|
||||||
latestRun: latestRun ?? undefined,
|
latestRun: latestRun ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberProperty(key: string, value: unknown) {
|
||||||
|
const n = value == null ? undefined : Number(value);
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? { [key]: n } : {};
|
||||||
|
}
|
||||||
|
|
||||||
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -813,6 +831,50 @@ function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
||||||
return conversationRunSummaryFromRow(row);
|
return conversationRunSummaryFromRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function totalConversationRunDurationMs(db: SqliteDb, conversationId: string): number | undefined {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT SUM(${terminalRunDurationSql()}) AS totalDurationMs
|
||||||
|
FROM messages
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
AND role = 'assistant'
|
||||||
|
AND run_status IN ('succeeded', 'failed', 'canceled')`,
|
||||||
|
)
|
||||||
|
.get(conversationId) as DbRow | undefined;
|
||||||
|
return row?.totalDurationMs == null ? undefined : Number(row.totalDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalRunDurationSql(alias?: string) {
|
||||||
|
const p = alias ? `${alias}.` : '';
|
||||||
|
return `CASE
|
||||||
|
WHEN ${p}started_at IS NOT NULL AND ${p}ended_at IS NOT NULL THEN
|
||||||
|
CASE
|
||||||
|
WHEN CAST(${p}ended_at AS INTEGER) >= CAST(${p}started_at AS INTEGER)
|
||||||
|
THEN CAST(${p}ended_at AS INTEGER) - CAST(${p}started_at AS INTEGER)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
ELSE (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN json_extract(usage_event.value, '$.durationMs') >= 0
|
||||||
|
THEN json_extract(usage_event.value, '$.durationMs')
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
FROM json_each(
|
||||||
|
CASE
|
||||||
|
WHEN json_valid(${p}events_json) AND json_type(${p}events_json) = 'array'
|
||||||
|
THEN ${p}events_json
|
||||||
|
ELSE '[]'
|
||||||
|
END
|
||||||
|
) AS usage_event
|
||||||
|
WHERE usage_event.type = 'object'
|
||||||
|
AND json_extract(usage_event.value, '$.kind') = 'usage'
|
||||||
|
AND json_type(usage_event.value, '$.durationMs') IN ('integer', 'real')
|
||||||
|
ORDER BY CAST(usage_event.key AS INTEGER) DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
END`;
|
||||||
|
}
|
||||||
|
|
||||||
function conversationRunSummaryFromRow(row: DbRow | undefined) {
|
function conversationRunSummaryFromRow(row: DbRow | undefined) {
|
||||||
if (!row || typeof row.runStatus !== 'string') return null;
|
if (!row || typeof row.runStatus !== 'string') return null;
|
||||||
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);
|
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { access, constants as fsConstants } from 'node:fs/promises';
|
import { access, constants as fsConstants } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
import type { Express } from 'express';
|
import type { Express } from 'express';
|
||||||
import type {
|
import type {
|
||||||
HostEditor,
|
HostEditor,
|
||||||
|
|
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectHostOpenDir(
|
||||||
|
projectsRoot: string,
|
||||||
|
project: { id: string; metadata?: { baseDir?: unknown } | null },
|
||||||
|
resolveProjectDir: (
|
||||||
|
projectsRoot: string,
|
||||||
|
projectId: string,
|
||||||
|
metadata?: unknown,
|
||||||
|
opts?: { allowUnavailableSandboxImportedProject?: boolean },
|
||||||
|
) => string,
|
||||||
|
): string {
|
||||||
|
const importedBaseDir =
|
||||||
|
typeof project.metadata?.baseDir === 'string'
|
||||||
|
? path.normalize(project.metadata.baseDir)
|
||||||
|
: '';
|
||||||
|
if (importedBaseDir && path.isAbsolute(importedBaseDir)) {
|
||||||
|
return importedBaseDir;
|
||||||
|
}
|
||||||
|
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
|
||||||
|
allowUnavailableSandboxImportedProject: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
|
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
|
||||||
const { db } = ctx;
|
const { db } = ctx;
|
||||||
const { sendApiError } = ctx.http;
|
const { sendApiError } = ctx.http;
|
||||||
|
|
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||||
}
|
}
|
||||||
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
const resolvedDir = projectHostOpenDir(
|
||||||
|
PROJECTS_DIR,
|
||||||
|
project,
|
||||||
|
resolveProjectDir,
|
||||||
|
);
|
||||||
const probe = await resolveEntry(entry);
|
const probe = await resolveEntry(entry);
|
||||||
if (!probe.available || !probe.launch) {
|
if (!probe.available || !probe.launch) {
|
||||||
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);
|
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Express } from 'express';
|
import type { Express } from 'express';
|
||||||
|
import nodePath from 'node:path';
|
||||||
import type { RouteDeps } from './server-context.js';
|
import type { RouteDeps } from './server-context.js';
|
||||||
import {
|
import {
|
||||||
InlineAssetsLimitError,
|
InlineAssetsLimitError,
|
||||||
|
|
@ -358,7 +359,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
||||||
const { sendApiError } = ctx.http;
|
const { sendApiError } = ctx.http;
|
||||||
const { PROJECTS_DIR } = ctx.paths;
|
const { PROJECTS_DIR } = ctx.paths;
|
||||||
const { getProject } = ctx.projectStore;
|
const { getProject } = ctx.projectStore;
|
||||||
const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
|
const { listFiles, readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
|
||||||
const { isSafeId } = ctx.validation;
|
const { isSafeId } = ctx.validation;
|
||||||
const {
|
const {
|
||||||
buildProjectArchive,
|
buildProjectArchive,
|
||||||
|
|
@ -447,6 +448,30 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/export/manifest', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isSafeId(req.params.id)) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
||||||
|
}
|
||||||
|
const project = getProject(db, req.params.id);
|
||||||
|
if (!project) {
|
||||||
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||||
|
}
|
||||||
|
const files = await listFiles(PROJECTS_DIR, req.params.id, {
|
||||||
|
metadata: project.metadata,
|
||||||
|
});
|
||||||
|
/** @type {import('@open-design/contracts').ProjectExportManifestResponse} */
|
||||||
|
const body = buildProjectExportManifestResponse({
|
||||||
|
project,
|
||||||
|
projectId: req.params.id,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/projects/:id/export/pdf', async (req, res) => {
|
app.post('/api/projects/:id/export/pdf', async (req, res) => {
|
||||||
if (typeof desktopPdfExporter !== 'function') {
|
if (typeof desktopPdfExporter !== 'function') {
|
||||||
return sendApiError(
|
return sendApiError(
|
||||||
|
|
@ -656,6 +681,177 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProjectExportManifestResponse({
|
||||||
|
project,
|
||||||
|
projectId,
|
||||||
|
files,
|
||||||
|
}: {
|
||||||
|
project: any;
|
||||||
|
projectId: string;
|
||||||
|
files: any[];
|
||||||
|
}) {
|
||||||
|
const sortedFiles = [...files].sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||||
|
const filesByName = new Map(sortedFiles.map((file) => [file.name, file]));
|
||||||
|
const reasons = new Map<string, Set<string>>();
|
||||||
|
const supportingNames = new Set<string>();
|
||||||
|
const artifactNames = new Set<string>();
|
||||||
|
const artifacts = [];
|
||||||
|
|
||||||
|
const note = (name: unknown, reason: string) => {
|
||||||
|
if (typeof name !== 'string' || !filesByName.has(name)) return;
|
||||||
|
if (!reasons.has(name)) reasons.set(name, new Set());
|
||||||
|
reasons.get(name)?.add(reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const file of sortedFiles) {
|
||||||
|
const manifest = file.artifactManifest && typeof file.artifactManifest === 'object'
|
||||||
|
? file.artifactManifest
|
||||||
|
: null;
|
||||||
|
if (!manifest) continue;
|
||||||
|
if (isInferredArtifactManifest(manifest)) continue;
|
||||||
|
artifactNames.add(file.name);
|
||||||
|
note(file.name, 'artifact-manifest');
|
||||||
|
|
||||||
|
const artifactSupporting = new Set<string>();
|
||||||
|
const addManifestRef = (
|
||||||
|
ref: unknown,
|
||||||
|
reason: string,
|
||||||
|
options: { allowProjectRootFallback?: boolean; preferProjectRoot?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
const ownerRelative = normalizeManifestProjectRef(ref, file.name);
|
||||||
|
const projectRoot = normalizeManifestProjectRootRef(ref);
|
||||||
|
const candidates = options.preferProjectRoot
|
||||||
|
? [projectRoot, ownerRelative]
|
||||||
|
: [
|
||||||
|
ownerRelative,
|
||||||
|
...(options.allowProjectRootFallback ? [projectRoot] : []),
|
||||||
|
];
|
||||||
|
const normalized = candidates.find((candidate) => candidate && filesByName.has(candidate));
|
||||||
|
if (!normalized) return;
|
||||||
|
if (normalized === file.name) return;
|
||||||
|
supportingNames.add(normalized);
|
||||||
|
artifactSupporting.add(normalized);
|
||||||
|
note(normalized, reason);
|
||||||
|
};
|
||||||
|
addManifestRef(manifest.entry, 'artifact-entry', { preferProjectRoot: true });
|
||||||
|
if (typeof manifest.primary === 'string') {
|
||||||
|
addManifestRef(manifest.primary, 'artifact-primary', { preferProjectRoot: true });
|
||||||
|
}
|
||||||
|
if (Array.isArray(manifest.supportingFiles)) {
|
||||||
|
for (const ref of manifest.supportingFiles) {
|
||||||
|
addManifestRef(ref, 'artifact-supporting-file', { allowProjectRootFallback: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts.push({
|
||||||
|
file: file.name,
|
||||||
|
title: typeof manifest.title === 'string' && manifest.title.trim()
|
||||||
|
? manifest.title
|
||||||
|
: file.name,
|
||||||
|
kind: typeof manifest.kind === 'string' ? manifest.kind : (file.artifactKind ?? null),
|
||||||
|
renderer: typeof manifest.renderer === 'string' ? manifest.renderer : null,
|
||||||
|
status: typeof manifest.status === 'string' ? manifest.status : null,
|
||||||
|
exports: Array.isArray(manifest.exports)
|
||||||
|
? manifest.exports.filter((value: unknown): value is string => typeof value === 'string')
|
||||||
|
: [],
|
||||||
|
supportingFiles: Array.from(artifactSupporting).sort((a, b) => a.localeCompare(b)),
|
||||||
|
updatedAt: typeof manifest.updatedAt === 'string' ? manifest.updatedAt : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryFile = chooseExportManifestEntryFile(project, sortedFiles, filesByName);
|
||||||
|
note(entryFile, 'project-entry-file');
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema: 'open-design.project-export-manifest.v1',
|
||||||
|
projectId,
|
||||||
|
projectName: typeof project?.name === 'string' ? project.name : null,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
entryFile,
|
||||||
|
files: sortedFiles.map((file) => ({
|
||||||
|
...file,
|
||||||
|
included: true,
|
||||||
|
role: roleForExportManifestFile(file, {
|
||||||
|
entryFile,
|
||||||
|
artifactNames,
|
||||||
|
supportingNames,
|
||||||
|
}),
|
||||||
|
reasons: Array.from(reasons.get(file.name) ?? ['visible-project-file']).sort((a, b) => a.localeCompare(b)),
|
||||||
|
})),
|
||||||
|
artifacts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInferredArtifactManifest(manifest: any): boolean {
|
||||||
|
return manifest?.metadata &&
|
||||||
|
typeof manifest.metadata === 'object' &&
|
||||||
|
manifest.metadata.inferred === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseExportManifestEntryFile(
|
||||||
|
project: any,
|
||||||
|
files: any[],
|
||||||
|
filesByName: Map<string, any>,
|
||||||
|
): string | null {
|
||||||
|
const metadataEntry = typeof project?.metadata?.entryFile === 'string'
|
||||||
|
? project.metadata.entryFile
|
||||||
|
: null;
|
||||||
|
if (metadataEntry && filesByName.has(metadataEntry)) return metadataEntry;
|
||||||
|
for (const file of files) {
|
||||||
|
const manifest = file.artifactManifest;
|
||||||
|
if (!manifest || typeof manifest !== 'object') continue;
|
||||||
|
if (isInferredArtifactManifest(manifest)) continue;
|
||||||
|
if (manifest.primary === true) return file.name;
|
||||||
|
if (typeof manifest.primary === 'string') {
|
||||||
|
const rootPrimary = normalizeManifestProjectRootRef(manifest.primary);
|
||||||
|
if (rootPrimary && filesByName.has(rootPrimary)) return rootPrimary;
|
||||||
|
const ownerRelativePrimary = normalizeManifestProjectRef(manifest.primary, file.name);
|
||||||
|
if (ownerRelativePrimary && filesByName.has(ownerRelativePrimary)) return ownerRelativePrimary;
|
||||||
|
}
|
||||||
|
const rootEntry = normalizeManifestProjectRootRef(manifest.entry);
|
||||||
|
if (rootEntry && filesByName.has(rootEntry)) return rootEntry;
|
||||||
|
const ownerRelativeEntry = normalizeManifestProjectRef(manifest.entry, file.name);
|
||||||
|
if (ownerRelativeEntry && filesByName.has(ownerRelativeEntry)) return ownerRelativeEntry;
|
||||||
|
}
|
||||||
|
return files.find((file) => /(^|\/)index\.html?$/i.test(file.name))?.name
|
||||||
|
?? files.find((file) => file.kind === 'html')?.name
|
||||||
|
?? files[0]?.name
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManifestProjectRootRef(ref: unknown): string | null {
|
||||||
|
return normalizeManifestProjectRef(ref, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManifestProjectRef(ref: unknown, ownerFile: string): string | null {
|
||||||
|
if (typeof ref !== 'string' || !ref.trim()) return null;
|
||||||
|
const value = ref.trim();
|
||||||
|
if (value.includes('\0') || value.startsWith('/')) return null;
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
||||||
|
const ownerDir = nodePath.posix.dirname(ownerFile);
|
||||||
|
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
|
||||||
|
const normalized = nodePath.posix.normalize(joined).replace(/^\.\//, '');
|
||||||
|
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
|
||||||
|
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleForExportManifestFile(
|
||||||
|
file: any,
|
||||||
|
refs: {
|
||||||
|
entryFile: string | null;
|
||||||
|
artifactNames: Set<string>;
|
||||||
|
supportingNames: Set<string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (file.name === refs.entryFile) return 'entry';
|
||||||
|
if (refs.artifactNames.has(file.name)) return 'artifact';
|
||||||
|
if (refs.supportingNames.has(file.name)) return 'supporting';
|
||||||
|
if (file.kind === 'image' || file.kind === 'video' || file.kind === 'audio') return 'asset';
|
||||||
|
if (file.kind === 'code' || file.kind === 'text') return 'source';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
|
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
|
||||||
|
|
||||||
export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) {
|
export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
|
||||||
{ id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible images/generations + images/edits (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' },
|
{ id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible images/generations + images/edits (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' },
|
||||||
{ id: 'comfyui', label: 'ComfyUI', hint: 'Local JSON workflow server (planned adapter)', integrated: false, defaultBaseUrl: 'http://127.0.0.1:8188', docsUrl: 'https://docs.comfy.org/development/core-concepts/workflow' },
|
{ id: 'comfyui', label: 'ComfyUI', hint: 'Local JSON workflow server (planned adapter)', integrated: false, defaultBaseUrl: 'http://127.0.0.1:8188', docsUrl: 'https://docs.comfy.org/development/core-concepts/workflow' },
|
||||||
{ id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' },
|
{ id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' },
|
||||||
{ id: 'fal', label: 'Fal.ai', hint: 'Sora / Seedance / Veo / FLUX', integrated: false, defaultBaseUrl: 'https://fal.run' },
|
{ id: 'fal', label: 'Fal.ai', hint: 'FLUX / Sora / Veo / Wan / Ideogram / Recraft and any fal-ai/* model', integrated: true, defaultBaseUrl: 'https://fal.run', supportsCustomModel: true },
|
||||||
{ id: 'leonardo', label: 'Leonardo.ai', hint: 'Phoenix / Kino XL / FLUX', integrated: true, credentialsRequired: true, settingsVisible: true, defaultBaseUrl: 'https://cloud.leonardo.ai/api/rest/v1' },
|
{ id: 'leonardo', label: 'Leonardo.ai', hint: 'Phoenix / Kino XL / FLUX', integrated: true, credentialsRequired: true, settingsVisible: true, defaultBaseUrl: 'https://cloud.leonardo.ai/api/rest/v1' },
|
||||||
{ id: 'replicate', label: 'Replicate', hint: 'FLUX / SDXL / Ideogram', integrated: false, defaultBaseUrl: 'https://api.replicate.com' },
|
{ id: 'replicate', label: 'Replicate', hint: 'FLUX / SDXL / Ideogram', integrated: false, defaultBaseUrl: 'https://api.replicate.com' },
|
||||||
{ id: 'google', label: 'Google AI / Vertex', hint: 'Imagen 4 / Veo 3 / Lyria', integrated: false },
|
{ id: 'google', label: 'Google AI / Vertex', hint: 'Imagen 4 / Veo 3 / Lyria', integrated: false },
|
||||||
|
|
@ -107,7 +107,13 @@ export const IMAGE_MODELS: MediaModel[] = [
|
||||||
|
|
||||||
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
|
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
|
||||||
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
|
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
|
||||||
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
|
|
||||||
|
{ id: 'flux-pro-ultra', label: 'flux-pro-ultra', hint: 'Fal · FLUX 1.1 Pro Ultra · highest quality (~60–180s)', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'flux-dev-fal', label: 'flux-dev (fal)', hint: 'Fal · FLUX Dev · balanced quality/speed (~15–40s)', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'flux-schnell-fal', label: 'flux-schnell (fal)', hint: 'Fal · FLUX Schnell · fastest (~3–8s)', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'ideogram-v3-fal', label: 'ideogram-v3', hint: 'Fal · Ideogram v3 · typography + design (~15–30s)', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'recraft-v3-fal', label: 'recraft-v3', hint: 'Fal · Recraft v3 · vector + illustration (~15–30s)', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5 (~20–40s)', provider: 'fal', caps: ['t2i'] },
|
||||||
|
|
||||||
{ id: 'leonardo-phoenix', label: 'Phoenix', hint: 'Leonardo · versatile', provider: 'leonardo', caps: ['t2i'] },
|
{ id: 'leonardo-phoenix', label: 'Phoenix', hint: 'Leonardo · versatile', provider: 'leonardo', caps: ['t2i'] },
|
||||||
{ id: 'leonardo-kino-xl', label: 'Kino XL', hint: 'Leonardo · cinematic', provider: 'leonardo', caps: ['t2i'] },
|
{ id: 'leonardo-kino-xl', label: 'Kino XL', hint: 'Leonardo · cinematic', provider: 'leonardo', caps: ['t2i'] },
|
||||||
|
|
@ -138,8 +144,14 @@ export const VIDEO_MODELS: MediaModel[] = [
|
||||||
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
|
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
|
||||||
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
|
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
|
||||||
|
|
||||||
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
|
{ id: 'veo-3-fal', label: 'veo-3 (fal)', hint: 'Fal · Google Veo 3 · sound-on', provider: 'fal', caps: ['t2v', 'audio'] },
|
||||||
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
|
{ id: 'veo-2-fal', label: 'veo-2 (fal)', hint: 'Fal · Google Veo 2', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'wan-2.1-t2v', label: 'wan-2.1-t2v', hint: 'Fal · Wan 2.1 text-to-video', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'wan-2.1-i2v', label: 'wan-2.1-i2v', hint: 'Fal · Wan 2.1 image-to-video', provider: 'fal', caps: ['i2v'] },
|
||||||
|
{ id: 'seedance-1-pro-fal', label: 'seedance-1-pro (fal)', hint: 'Fal · Seedance 1 Pro', provider: 'fal', caps: ['t2v', 'i2v'] },
|
||||||
|
{ id: 'kling-2.1-t2v-fal', label: 'kling-2.1 (fal)', hint: 'Fal · Kling 2.1 Pro text-to-video', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'sora-2', label: 'sora-2', hint: 'Fal · OpenAI Sora 2', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'Fal · OpenAI Sora 2 Pro', provider: 'fal', caps: ['t2v'] },
|
||||||
|
|
||||||
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
|
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
|
||||||
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },
|
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },
|
||||||
|
|
|
||||||
|
|
@ -327,27 +327,42 @@ export async function generateMedia(args: {
|
||||||
`unsupported audioKind: ${audioKind}. Allowed: music | speech | sfx.`,
|
`unsupported audioKind: ${audioKind}. Allowed: music | speech | sfx.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const def = findMediaModel(model);
|
// Arbitrary fal.ai model paths (e.g. "fal-ai/flux/dev") bypass the
|
||||||
|
// catalog so users can reach any model on fal without waiting for a
|
||||||
|
// catalog entry. Surface comes from the caller; no cross-surface guard
|
||||||
|
// is needed because the fal renderer reads ctx.surface directly.
|
||||||
|
let def = findMediaModel(model);
|
||||||
|
let isFalCustomPath = false;
|
||||||
if (!def) {
|
if (!def) {
|
||||||
throw new Error(
|
if (/^fal-ai\//.test(model)) {
|
||||||
`unknown model: ${model}. Pass --model from the registered list (see /api/media/models).`,
|
isFalCustomPath = true;
|
||||||
);
|
def = {
|
||||||
|
id: model,
|
||||||
|
label: model,
|
||||||
|
hint: 'Fal.ai',
|
||||||
|
provider: 'fal',
|
||||||
|
caps: surface === 'image' ? ['t2i'] : surface === 'video' ? ['t2v'] : [],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`unknown model: ${model}. Pass --model from the registered list (see /api/media/models), ` +
|
||||||
|
`or pass a full fal-ai/* path (e.g. fal-ai/flux/dev) for any Fal model.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Reject cross-surface combinations (e.g. surface=image + model=seedance-2)
|
// Reject cross-surface combinations for catalogued models.
|
||||||
// here so the dispatcher never silently routes a video model id through
|
|
||||||
// the image renderer. We compare against the surface-specific list — for
|
|
||||||
// audio we further restrict to the kind-specific bucket so a `music`
|
|
||||||
// surface can't bill an `elevenlabs-v3` (speech) call.
|
|
||||||
const resolvedAudioKind =
|
const resolvedAudioKind =
|
||||||
surface === 'audio' ? audioKind || 'music' : undefined;
|
surface === 'audio' ? audioKind || 'music' : undefined;
|
||||||
const allowed = modelsForSurface(surface, resolvedAudioKind);
|
if (!isFalCustomPath) {
|
||||||
if (!allowed.some((m) => m.id === model)) {
|
const allowed = modelsForSurface(surface, resolvedAudioKind);
|
||||||
const ids = allowed.map((m) => m.id).join(', ');
|
if (!allowed.some((m) => m.id === model)) {
|
||||||
const where =
|
const ids = allowed.map((m) => m.id).join(', ');
|
||||||
surface === 'audio' ? `audio · ${resolvedAudioKind}` : surface;
|
const where =
|
||||||
throw new Error(
|
surface === 'audio' ? `audio · ${resolvedAudioKind}` : surface;
|
||||||
`model "${model}" is not registered for surface "${where}". Allowed: ${ids}.`,
|
throw new Error(
|
||||||
);
|
`model "${model}" is not registered for surface "${where}". Allowed: ${ids}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp registry-bound numeric inputs to their allowed buckets so a
|
// Clamp registry-bound numeric inputs to their allowed buckets so a
|
||||||
|
|
@ -575,6 +590,16 @@ export async function generateMedia(args: {
|
||||||
bytes = result.bytes;
|
bytes = result.bytes;
|
||||||
providerNote = result.providerNote;
|
providerNote = result.providerNote;
|
||||||
suggestedExt = result.suggestedExt;
|
suggestedExt = result.suggestedExt;
|
||||||
|
} else if (def.provider === 'fal' && surface === 'image') {
|
||||||
|
const result = await renderFalImage(ctx, credentials);
|
||||||
|
bytes = result.bytes;
|
||||||
|
providerNote = result.providerNote;
|
||||||
|
suggestedExt = result.suggestedExt;
|
||||||
|
} else if (def.provider === 'fal' && surface === 'video') {
|
||||||
|
const result = await renderFalVideo(ctx, credentials, args.onProgress);
|
||||||
|
bytes = result.bytes;
|
||||||
|
providerNote = result.providerNote;
|
||||||
|
suggestedExt = result.suggestedExt;
|
||||||
} else {
|
} else {
|
||||||
// No real renderer wired up for this (provider, surface). Gate the
|
// No real renderer wired up for this (provider, surface). Gate the
|
||||||
// stub fallback behind OD_MEDIA_ALLOW_STUBS so release builds don't
|
// stub fallback behind OD_MEDIA_ALLOW_STUBS so release builds don't
|
||||||
|
|
@ -2498,6 +2523,270 @@ async function renderFishAudioTTS(ctx: MediaContext, credentials: ProviderConfig
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider: Fal.ai — generic queue-based renderer for image + video.
|
||||||
|
//
|
||||||
|
// Queue protocol (raw HTTP, no SDK):
|
||||||
|
// POST https://queue.fal.run/{endpoint} body: flat model input (no wrapper)
|
||||||
|
// GET {status_url}?logs=0 → { status: QUEUED|IN_PROGRESS|COMPLETED|FAILED }
|
||||||
|
// GET {response_url} → result payload
|
||||||
|
//
|
||||||
|
// Image result shape: { images: [{ url, content_type }] }
|
||||||
|
// Video result shape: { video: { url } } or { videos: [{ url }] }
|
||||||
|
//
|
||||||
|
// Endpoint resolution: FAL_ENDPOINTS maps catalogue IDs to their fal-ai/*
|
||||||
|
// path. Any model ID not in the map is used verbatim — this is what
|
||||||
|
// enables arbitrary "fal-ai/..." custom paths without catalog entries.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FAL_ENDPOINTS: Record<string, string> = {
|
||||||
|
'sd-3.5': 'fal-ai/stable-diffusion-v35-large',
|
||||||
|
'flux-pro-ultra': 'fal-ai/flux-pro/v1.1-ultra',
|
||||||
|
'flux-dev-fal': 'fal-ai/flux/dev',
|
||||||
|
'flux-schnell-fal': 'fal-ai/flux/schnell',
|
||||||
|
'ideogram-v3-fal': 'fal-ai/ideogram/v3',
|
||||||
|
'recraft-v3-fal': 'fal-ai/recraft-v3',
|
||||||
|
'sora-2': 'fal-ai/sora',
|
||||||
|
'sora-2-pro': 'fal-ai/sora',
|
||||||
|
'veo-3-fal': 'fal-ai/veo3',
|
||||||
|
'veo-2-fal': 'fal-ai/veo2',
|
||||||
|
'wan-2.1-t2v': 'fal-ai/wan-t2v',
|
||||||
|
'wan-2.1-i2v': 'fal-ai/wan-i2v',
|
||||||
|
'seedance-1-pro-fal': 'fal-ai/bytedance/seedance-1-pro',
|
||||||
|
'kling-2.1-t2v-fal': 'fal-ai/kling-video/v2.1/master/text-to-video',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image models that expect `aspect_ratio` (e.g. "16:9") instead of the
|
||||||
|
// named `image_size` enum ("landscape_16_9") used by FLUX Dev/Schnell/SD.
|
||||||
|
const FAL_IMAGE_USES_ASPECT_RATIO = new Set([
|
||||||
|
'fal-ai/flux-pro/v1.1-ultra',
|
||||||
|
'fal-ai/flux-pro/v1.1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FAL_IMAGE_SIZES: Record<string, string> = {
|
||||||
|
'1:1': 'square_hd',
|
||||||
|
'16:9': 'landscape_16_9',
|
||||||
|
'9:16': 'portrait_16_9',
|
||||||
|
'4:3': 'landscape_4_3',
|
||||||
|
'3:4': 'portrait_4_3',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Video models that do not accept a duration field at all.
|
||||||
|
const FAL_VIDEO_NO_DURATION = new Set([
|
||||||
|
'fal-ai/wan-t2v',
|
||||||
|
'fal-ai/wan-i2v',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Video models that expect duration as a suffixed string ("4s"/"6s"/"8s") and
|
||||||
|
// only accept those specific buckets.
|
||||||
|
const FAL_VIDEO_STRING_DURATION = new Set([
|
||||||
|
'fal-ai/veo3',
|
||||||
|
'fal-ai/veo2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Valid Veo duration buckets (seconds). Nearest-bucket clamp applied below.
|
||||||
|
const FAL_VEO_DURATION_BUCKETS = [4, 6, 8];
|
||||||
|
|
||||||
|
async function falQueueRun(
|
||||||
|
endpoint: string,
|
||||||
|
queueBase: string,
|
||||||
|
apiKey: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
maxMs: number,
|
||||||
|
onProgress?: ProgressFn,
|
||||||
|
modelLabel?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const authHeader = { 'authorization': `Key ${apiKey}` };
|
||||||
|
|
||||||
|
const submitResp = await fetch(`${queueBase}/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...authHeader, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const submitText = await submitResp.text();
|
||||||
|
if (!submitResp.ok) {
|
||||||
|
throw new Error(`fal submit ${submitResp.status}: ${truncate(submitText, 240)}`);
|
||||||
|
}
|
||||||
|
let submitData: any;
|
||||||
|
try { submitData = JSON.parse(submitText); } catch {
|
||||||
|
throw new Error(`fal submit non-JSON: ${truncate(submitText, 200)}`);
|
||||||
|
}
|
||||||
|
const requestId: string = submitData?.request_id;
|
||||||
|
if (!requestId) {
|
||||||
|
throw new Error(`fal submit missing request_id: ${truncate(submitText, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the URLs returned by the submit response; fall back to the
|
||||||
|
// well-known model-agnostic queue paths as a safety net.
|
||||||
|
const statusUrl = submitData.status_url
|
||||||
|
?? `${queueBase}/requests/${encodeURIComponent(requestId)}/status?logs=0`;
|
||||||
|
const resultUrl = submitData.response_url
|
||||||
|
?? `${queueBase}/requests/${encodeURIComponent(requestId)}`;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let lastStatus = '';
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(`fal ${modelLabel || endpoint} task ${requestId.slice(0, 8)} accepted; polling…`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstPoll = true;
|
||||||
|
while (Date.now() - startedAt < maxMs) {
|
||||||
|
if (!firstPoll) await sleep(3000);
|
||||||
|
firstPoll = false;
|
||||||
|
const statusResp = await fetch(statusUrl, { headers: authHeader });
|
||||||
|
const statusText = await statusResp.text();
|
||||||
|
if (!statusResp.ok) {
|
||||||
|
throw new Error(`fal poll ${statusResp.status}: ${truncate(statusText, 240)}`);
|
||||||
|
}
|
||||||
|
let statusData: any;
|
||||||
|
try { statusData = JSON.parse(statusText); } catch {
|
||||||
|
throw new Error(`fal poll non-JSON: ${truncate(statusText, 200)}`);
|
||||||
|
}
|
||||||
|
lastStatus = statusData?.status || '';
|
||||||
|
if (onProgress) {
|
||||||
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||||
|
onProgress(`fal task ${requestId.slice(0, 8)} status=${lastStatus} (${elapsed}s)`);
|
||||||
|
}
|
||||||
|
if (lastStatus === 'COMPLETED') {
|
||||||
|
const resultResp = await fetch(resultUrl, { headers: authHeader });
|
||||||
|
const resultText = await resultResp.text();
|
||||||
|
if (!resultResp.ok) {
|
||||||
|
throw new Error(`fal result ${resultResp.status}: ${truncate(resultText, 240)}`);
|
||||||
|
}
|
||||||
|
try { return JSON.parse(resultText); } catch {
|
||||||
|
throw new Error(`fal result non-JSON: ${truncate(resultText, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastStatus === 'FAILED') {
|
||||||
|
const errRaw = statusData?.error?.message
|
||||||
|
?? (typeof statusData?.error === 'string' ? statusData.error : null)
|
||||||
|
?? 'unknown error';
|
||||||
|
throw new Error(`fal task failed: ${errRaw}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||||
|
const ceil = Math.round(maxMs / 1000);
|
||||||
|
throw new Error(
|
||||||
|
`fal timed out after ${elapsed}s waiting for COMPLETED ` +
|
||||||
|
`(last status: ${lastStatus || 'unknown'}, ceiling ${ceil}s). ` +
|
||||||
|
`Raise OD_FAL_MAX_POLL_MS to extend the ceiling.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function falMaxPollMs(defaultMs: number): number {
|
||||||
|
const v = Number(process.env.OD_FAL_MAX_POLL_MS);
|
||||||
|
return Number.isFinite(v) && v >= 30_000 ? v : defaultMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function falQueueBase(baseUrl: string): string {
|
||||||
|
if (baseUrl.includes('queue.fal.run')) return baseUrl;
|
||||||
|
// Replace only the exact host to avoid mangling custom base URLs that
|
||||||
|
// happen to contain "fal.run" as a substring.
|
||||||
|
return baseUrl.replace(/^https:\/\/fal\.run/, 'https://queue.fal.run');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFalImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
|
||||||
|
if (!credentials.apiKey) {
|
||||||
|
throw new Error('no Fal API key — configure it in Settings or set FAL_KEY');
|
||||||
|
}
|
||||||
|
const queueBase = falQueueBase((credentials.baseUrl || 'https://fal.run').replace(/\/$/, ''));
|
||||||
|
const endpoint = FAL_ENDPOINTS[ctx.model] ?? ctx.model;
|
||||||
|
const aspectRatio = ctx.aspect ?? '1:1';
|
||||||
|
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
prompt: ctx.prompt || 'A high-quality image.',
|
||||||
|
num_images: 1,
|
||||||
|
};
|
||||||
|
// flux-pro-ultra and similar pro variants expect `aspect_ratio` as a
|
||||||
|
// ratio string; most other fal image models use a named `image_size`.
|
||||||
|
if (FAL_IMAGE_USES_ASPECT_RATIO.has(endpoint)) {
|
||||||
|
input.aspect_ratio = aspectRatio;
|
||||||
|
} else {
|
||||||
|
input.image_size = FAL_IMAGE_SIZES[aspectRatio] ?? 'square_hd';
|
||||||
|
}
|
||||||
|
if (ctx.imageRef?.dataUrl) {
|
||||||
|
input.image_url = ctx.imageRef.dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await falQueueRun(endpoint, queueBase, credentials.apiKey, input, falMaxPollMs(5 * 60 * 1000));
|
||||||
|
|
||||||
|
const imageEntry = Array.isArray(result?.images) ? result.images[0] : null;
|
||||||
|
if (!imageEntry?.url) {
|
||||||
|
throw new Error(`fal image missing images[0].url: ${truncate(JSON.stringify(result), 200)}`);
|
||||||
|
}
|
||||||
|
const dlResp = await fetch(imageEntry.url);
|
||||||
|
if (!dlResp.ok) throw new Error(`fal image download ${dlResp.status}`);
|
||||||
|
const bytes = Buffer.from(await dlResp.arrayBuffer());
|
||||||
|
const sizeLabel = FAL_IMAGE_USES_ASPECT_RATIO.has(endpoint) ? aspectRatio : (FAL_IMAGE_SIZES[aspectRatio] ?? 'square_hd');
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
providerNote: `fal/${endpoint} · ${sizeLabel} · ${bytes.length} bytes`,
|
||||||
|
suggestedExt: sniffImageExt(bytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFalVideo(ctx: MediaContext, credentials: ProviderConfig, onProgress?: ProgressFn): Promise<RenderResult> {
|
||||||
|
if (!credentials.apiKey) {
|
||||||
|
throw new Error('no Fal API key — configure it in Settings or set FAL_KEY');
|
||||||
|
}
|
||||||
|
const queueBase = falQueueBase((credentials.baseUrl || 'https://fal.run').replace(/\/$/, ''));
|
||||||
|
const endpoint = FAL_ENDPOINTS[ctx.model] ?? ctx.model;
|
||||||
|
const aspectRatio = ctx.aspect ?? '16:9';
|
||||||
|
const durationSec = ctx.length ?? 5;
|
||||||
|
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
prompt: ctx.prompt || 'A short cinematic clip.',
|
||||||
|
aspect_ratio: aspectRatio,
|
||||||
|
};
|
||||||
|
// Track the effective duration label (what we actually send upstream).
|
||||||
|
let effectiveDurationLabel: string | undefined;
|
||||||
|
let durationSnappedNote = '';
|
||||||
|
// Some models (Wan) have no duration parameter; others (Veo) require a
|
||||||
|
// suffixed string from a fixed bucket set ("4s"/"6s"/"8s").
|
||||||
|
if (!FAL_VIDEO_NO_DURATION.has(endpoint)) {
|
||||||
|
if (FAL_VIDEO_STRING_DURATION.has(endpoint)) {
|
||||||
|
const closest = FAL_VEO_DURATION_BUCKETS.reduce((a, b) =>
|
||||||
|
Math.abs(b - durationSec) < Math.abs(a - durationSec) ? b : a,
|
||||||
|
);
|
||||||
|
input.duration = `${closest}s`;
|
||||||
|
effectiveDurationLabel = `${closest}s`;
|
||||||
|
if (closest !== durationSec) {
|
||||||
|
durationSnappedNote = ` (requested ${durationSec}s → snapped to ${closest}s)`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input.duration = durationSec;
|
||||||
|
effectiveDurationLabel = `${durationSec}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ctx.imageRef?.dataUrl) {
|
||||||
|
input.image_url = ctx.imageRef.dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await falQueueRun(
|
||||||
|
endpoint, queueBase, credentials.apiKey, input,
|
||||||
|
falMaxPollMs(10 * 60 * 1000), onProgress, ctx.model,
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoUrl: string | null =
|
||||||
|
result?.video?.url
|
||||||
|
?? (Array.isArray(result?.videos) ? result.videos[0]?.url : null)
|
||||||
|
?? null;
|
||||||
|
if (!videoUrl) {
|
||||||
|
throw new Error(`fal video missing video.url: ${truncate(JSON.stringify(result), 200)}`);
|
||||||
|
}
|
||||||
|
const dlResp = await fetch(videoUrl);
|
||||||
|
if (!dlResp.ok) throw new Error(`fal video download ${dlResp.status}`);
|
||||||
|
const bytes = Buffer.from(await dlResp.arrayBuffer());
|
||||||
|
const durationPart = effectiveDurationLabel ? ` · ${effectiveDurationLabel}${durationSnappedNote}` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
providerNote: `fal/${endpoint} · ${aspectRatio}${durationPart} · ${bytes.length} bytes`,
|
||||||
|
suggestedExt: '.mp4',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider: HyperFrames — local HTML→MP4 renderer (heygen-com/hyperframes).
|
// Provider: HyperFrames — local HTML→MP4 renderer (heygen-com/hyperframes).
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -865,7 +865,13 @@ async function callLocalCli(provider, system, user, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = applyAgentLaunchEnv(
|
const env = applyAgentLaunchEnv(
|
||||||
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
|
spawnEnvForAgent(
|
||||||
|
def.id,
|
||||||
|
{ ...process.env, ...(def.env || {}) },
|
||||||
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: launch.selectedPath },
|
||||||
|
),
|
||||||
launch,
|
launch,
|
||||||
);
|
);
|
||||||
const invocation = createCommandInvocation({
|
const invocation = createCommandInvocation({
|
||||||
|
|
|
||||||
130
apps/daemon/src/project-locations.ts
Normal file
130
apps/daemon/src/project-locations.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { ProjectLocationPrefs } from './app-config.js';
|
||||||
|
import { expandHomePrefix } from './home-expansion.js';
|
||||||
|
import { isSafeId } from './projects.js';
|
||||||
|
|
||||||
|
export const BUILT_IN_PROJECT_LOCATION_ID = 'default';
|
||||||
|
export const PROJECT_MANIFEST_RELATIVE_PATH = path.join('.open-design', 'project.json');
|
||||||
|
|
||||||
|
export interface ProjectLocation extends ProjectLocationPrefs {
|
||||||
|
builtIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectManifest {
|
||||||
|
schemaVersion: 1;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
skillId?: string | null;
|
||||||
|
designSystemId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function builtInProjectLocation(projectsDir: string): ProjectLocation {
|
||||||
|
return {
|
||||||
|
id: BUILT_IN_PROJECT_LOCATION_ID,
|
||||||
|
name: 'Open Design projects',
|
||||||
|
path: projectsDir,
|
||||||
|
builtIn: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allProjectLocations(projectsDir: string, external: ProjectLocationPrefs[] | undefined): ProjectLocation[] {
|
||||||
|
return [builtInProjectLocation(projectsDir), ...(external ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function locationProjectDir(location: ProjectLocation, projectId: string): string {
|
||||||
|
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||||
|
return path.join(location.path, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertInsideLocation(locationRoot: string, projectDir: string): void {
|
||||||
|
const relative = path.relative(locationRoot, projectDir);
|
||||||
|
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
throw new Error('project directory escapes project location');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLocationProjectDir(location: ProjectLocation, projectId: string): Promise<string> {
|
||||||
|
const root = await realpath(location.path);
|
||||||
|
const target = locationProjectDir({ ...location, path: root }, projectId);
|
||||||
|
await mkdir(target, { recursive: false });
|
||||||
|
const info = await lstat(target);
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
|
||||||
|
const canonical = await realpath(target);
|
||||||
|
assertInsideLocation(root, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canonicalLocationChildDir(location: ProjectLocation, childName: string): Promise<string> {
|
||||||
|
const root = await realpath(location.path);
|
||||||
|
if (!isSafeId(childName)) throw new Error('invalid project directory name');
|
||||||
|
const target = path.join(root, childName);
|
||||||
|
const info = await lstat(target);
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
|
||||||
|
const canonical = await realpath(target);
|
||||||
|
assertInsideLocation(root, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestPath(projectDir: string): string {
|
||||||
|
return path.join(projectDir, PROJECT_MANIFEST_RELATIVE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureProjectLocation(locationPath: string): Promise<string> {
|
||||||
|
const expanded = expandHomePrefix(locationPath.trim());
|
||||||
|
if (!path.isAbsolute(expanded)) throw new Error(`project location must be an absolute path: ${locationPath}`);
|
||||||
|
await mkdir(expanded, { recursive: true });
|
||||||
|
return realpath(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeProjectManifest(projectDir: string, manifest: ProjectManifest): Promise<void> {
|
||||||
|
const file = manifestPath(projectDir);
|
||||||
|
await mkdir(path.dirname(file), { recursive: true });
|
||||||
|
await writeFile(file, JSON.stringify(manifest, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readProjectManifest(projectDir: string): Promise<ProjectManifest | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(manifestPath(projectDir), 'utf8');
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
if (obj.schemaVersion !== 1) return null;
|
||||||
|
if (typeof obj.id !== 'string' || !isSafeId(obj.id)) return null;
|
||||||
|
if (typeof obj.name !== 'string' || !obj.name.trim()) return null;
|
||||||
|
const createdAt = typeof obj.createdAt === 'number' && Number.isFinite(obj.createdAt) ? obj.createdAt : Date.now();
|
||||||
|
const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : createdAt;
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: obj.id,
|
||||||
|
name: obj.name.trim(),
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
skillId: typeof obj.skillId === 'string' ? obj.skillId : null,
|
||||||
|
designSystemId: typeof obj.designSystemId === 'string' ? obj.designSystemId : null,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { code?: string; name?: string };
|
||||||
|
if (e.code === 'ENOENT' || e.name === 'SyntaxError') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanProjectLocation(location: ProjectLocation): Promise<Array<{ dir: string; manifest: ProjectManifest }>> {
|
||||||
|
const entries = await readdir(location.path, { withFileTypes: true });
|
||||||
|
const found: Array<{ dir: string; manifest: ProjectManifest }> = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
let dir: string;
|
||||||
|
try {
|
||||||
|
dir = await canonicalLocationChildDir(location, entry.name);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const manifest = await readProjectManifest(dir);
|
||||||
|
if (manifest) found.push({ dir, manifest });
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Express } from 'express';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import type { Express, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
defaultScenarioPluginIdForProjectMetadata,
|
defaultScenarioPluginIdForProjectMetadata,
|
||||||
type PluginManifest,
|
type PluginManifest,
|
||||||
|
|
@ -18,9 +20,18 @@ import {
|
||||||
import { connectorService } from './connectors/service.js';
|
import { connectorService } from './connectors/service.js';
|
||||||
import type { RouteDeps } from './server-context.js';
|
import type { RouteDeps } from './server-context.js';
|
||||||
import { listSkills } from './skills.js';
|
import { listSkills } from './skills.js';
|
||||||
|
import { isSafeId } from './projects.js';
|
||||||
|
import {
|
||||||
|
BUILT_IN_PROJECT_LOCATION_ID,
|
||||||
|
allProjectLocations,
|
||||||
|
createLocationProjectDir,
|
||||||
|
ensureProjectLocation,
|
||||||
|
scanProjectLocation,
|
||||||
|
writeProjectManifest,
|
||||||
|
} from './project-locations.js';
|
||||||
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
||||||
|
|
||||||
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
|
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
|
||||||
|
|
||||||
function projectDetailResolvedDir(
|
function projectDetailResolvedDir(
|
||||||
projectsRoot: string,
|
projectsRoot: string,
|
||||||
|
|
@ -145,6 +156,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
const { db, design } = ctx;
|
const { db, design } = ctx;
|
||||||
const { sendApiError, createSseResponse } = ctx.http;
|
const { sendApiError, createSseResponse } = ctx.http;
|
||||||
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
|
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
|
||||||
|
const { readAppConfig, writeAppConfig } = ctx.appConfig;
|
||||||
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
|
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
|
||||||
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
|
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
|
||||||
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
|
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
|
||||||
|
|
@ -202,8 +214,199 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
return Array.from(byTaskKind.values());
|
return Array.from(byTaskKind.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/projects', (_req, res) => {
|
async function configuredProjectLocations() {
|
||||||
|
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
|
||||||
|
const all = allProjectLocations(PROJECTS_DIR, config.projectLocations);
|
||||||
|
const valid = all[0] ? [all[0]] : [];
|
||||||
|
for (const location of all.slice(1)) {
|
||||||
|
const validated = validateLinkedDirs([location.path]);
|
||||||
|
if (validated.error) continue;
|
||||||
|
const canonical = validated.dirs[0];
|
||||||
|
if (!canonical) continue;
|
||||||
|
if (locationOverlapsDaemonData(canonical)) continue;
|
||||||
|
valid.push({ ...location, path: canonical });
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationOverlapsDaemonData(locationPath: string): boolean {
|
||||||
|
const runtimeDir = ctx.paths.RUNTIME_DATA_DIR_CANONICAL || ctx.paths.RUNTIME_DATA_DIR;
|
||||||
|
const projectsDir = path.join(runtimeDir, 'projects');
|
||||||
|
const relativeToRuntime = pathRelative(runtimeDir, locationPath);
|
||||||
|
const runtimeInsideLocation = pathRelative(locationPath, runtimeDir);
|
||||||
|
const relativeToProjects = pathRelative(projectsDir, locationPath);
|
||||||
|
const projectsInsideLocation = pathRelative(locationPath, projectsDir);
|
||||||
|
return isInsideOrSame(relativeToRuntime) || isInsideOrSame(runtimeInsideLocation)
|
||||||
|
|| isInsideOrSame(relativeToProjects) || isInsideOrSame(projectsInsideLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathRelative(from: string, to: string): string {
|
||||||
|
return path.relative(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideOrSame(relative: string): boolean {
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectBelongsToLocation(project: any, location: { id: string; path: string }): boolean {
|
||||||
|
const metadata = project?.metadata;
|
||||||
|
if (typeof metadata?.baseDir !== 'string') return metadata?.projectLocationId === location.id;
|
||||||
|
const relative = path.relative(location.path, metadata.baseDir);
|
||||||
|
return isInsideOrSame(relative) && relative !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectLocationProject(project: any): boolean {
|
||||||
|
const metadata = project?.metadata;
|
||||||
|
return metadata?.importedFrom === 'project-location'
|
||||||
|
|| typeof metadata?.projectLocationId === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectVisibleForLocations(
|
||||||
|
project: any,
|
||||||
|
locations: Array<{ id: string; path: string; builtIn?: boolean }>,
|
||||||
|
): boolean {
|
||||||
|
if (!isProjectLocationProject(project)) return true;
|
||||||
|
return locations.some((location) => !location.builtIn && projectBelongsToLocation(project, location));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCreateProjectLocationId(explicitProjectLocationId: unknown): Promise<string> {
|
||||||
|
if (typeof explicitProjectLocationId === 'string' && explicitProjectLocationId.trim()) {
|
||||||
|
return explicitProjectLocationId.trim();
|
||||||
|
}
|
||||||
|
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
|
||||||
|
const configuredDefault = typeof config.defaultProjectLocationId === 'string'
|
||||||
|
? config.defaultProjectLocationId.trim()
|
||||||
|
: '';
|
||||||
|
if (!configuredDefault || configuredDefault === BUILT_IN_PROJECT_LOCATION_ID) {
|
||||||
|
return BUILT_IN_PROJECT_LOCATION_ID;
|
||||||
|
}
|
||||||
|
const locations = await configuredProjectLocations();
|
||||||
|
return locations.some((location) => !location.builtIn && location.id === configuredDefault)
|
||||||
|
? configuredDefault
|
||||||
|
: BUILT_IN_PROJECT_LOCATION_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterProjectsForRemovedLocations(
|
||||||
|
previousLocations: Array<{ id: string; path: string; builtIn?: boolean }>,
|
||||||
|
nextLocations: Array<{ id?: string; path: string }>,
|
||||||
|
): string[] {
|
||||||
|
const nextIds = new Set(nextLocations.map((location) => location.id).filter(Boolean));
|
||||||
|
const nextPaths = new Set(nextLocations.map((location) => location.path));
|
||||||
|
const removed = previousLocations.filter(
|
||||||
|
(location) => !location.builtIn && !nextIds.has(location.id) && !nextPaths.has(location.path),
|
||||||
|
);
|
||||||
|
if (removed.length === 0) return [];
|
||||||
|
return listProjects(db)
|
||||||
|
.filter((project: any) => removed.some((location) => projectBelongsToLocation(project, location)))
|
||||||
|
.map((project: any) => project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/project-locations', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const locations = await configuredProjectLocations();
|
||||||
|
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
|
||||||
|
const body = { locations };
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/project-locations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requested = Array.isArray(req.body?.locations) ? req.body.locations : null;
|
||||||
|
if (!requested) return sendApiError(res, 400, 'BAD_REQUEST', 'locations must be an array');
|
||||||
|
const previousLocations = await configuredProjectLocations();
|
||||||
|
const prepared = [];
|
||||||
|
for (const loc of requested) {
|
||||||
|
if (!loc || typeof loc !== 'object' || typeof loc.path !== 'string') continue;
|
||||||
|
const canonicalPath = await ensureProjectLocation(loc.path);
|
||||||
|
const validated = validateLinkedDirs([canonicalPath]);
|
||||||
|
if (validated.error) return sendApiError(res, 400, 'BAD_REQUEST', validated.error);
|
||||||
|
if (locationOverlapsDaemonData(canonicalPath)) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', 'project location cannot overlap daemon data');
|
||||||
|
}
|
||||||
|
prepared.push({
|
||||||
|
id: typeof loc.id === 'string' ? loc.id : undefined,
|
||||||
|
name: typeof loc.name === 'string' ? loc.name : undefined,
|
||||||
|
path: canonicalPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const config = await writeAppConfig(ctx.paths.RUNTIME_DATA_DIR, { projectLocations: prepared });
|
||||||
|
const locations = allProjectLocations(PROJECTS_DIR, config.projectLocations);
|
||||||
|
const removedProjectIds = unregisterProjectsForRemovedLocations(previousLocations, config.projectLocations ?? []);
|
||||||
|
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
|
||||||
|
const body = { locations, removedProjectIds };
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/project-locations/scan', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn);
|
||||||
|
const imported = [];
|
||||||
|
const existing: string[] = [];
|
||||||
|
const skipped: Array<{ path: string; reason: string }> = [];
|
||||||
|
let scanned = 0;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const location of locations) {
|
||||||
|
let found;
|
||||||
|
try {
|
||||||
|
found = await scanProjectLocation(location);
|
||||||
|
} catch (err: any) {
|
||||||
|
skipped.push({ path: location.path, reason: String(err?.message ?? err) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scanned += found.length;
|
||||||
|
for (const entry of found) {
|
||||||
|
const { manifest } = entry;
|
||||||
|
if (getProject(db, manifest.id)) {
|
||||||
|
existing.push(manifest.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const project = insertProject(db, {
|
||||||
|
id: manifest.id,
|
||||||
|
name: manifest.name,
|
||||||
|
skillId: manifest.skillId ?? null,
|
||||||
|
designSystemId: manifest.designSystemId ?? null,
|
||||||
|
pendingPrompt: null,
|
||||||
|
metadata: {
|
||||||
|
kind: 'prototype',
|
||||||
|
baseDir: entry.dir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: location.id,
|
||||||
|
},
|
||||||
|
customInstructions: null,
|
||||||
|
createdAt: manifest.createdAt,
|
||||||
|
updatedAt: manifest.updatedAt,
|
||||||
|
});
|
||||||
|
insertConversation(db, {
|
||||||
|
id: randomId(),
|
||||||
|
projectId: manifest.id,
|
||||||
|
title: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (project) imported.push(project);
|
||||||
|
} catch (err: any) {
|
||||||
|
skipped.push({ path: entry.dir, reason: String(err?.message ?? err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @type {import('@open-design/contracts').ScanProjectLocationsResponse} */
|
||||||
|
const body = { scanned, imported, existing, skipped };
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const locations = await configuredProjectLocations();
|
||||||
const latestRunStatuses = listLatestProjectRunStatuses(db);
|
const latestRunStatuses = listLatestProjectRunStatuses(db);
|
||||||
const awaitingInputProjects = listProjectsAwaitingInput(db);
|
const awaitingInputProjects = listProjectsAwaitingInput(db);
|
||||||
const activeRunStatuses = new Map();
|
const activeRunStatuses = new Map();
|
||||||
|
|
@ -224,15 +427,17 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
}
|
}
|
||||||
/** @type {import('@open-design/contracts').ProjectsResponse} */
|
/** @type {import('@open-design/contracts').ProjectsResponse} */
|
||||||
const body = {
|
const body = {
|
||||||
projects: listProjects(db).map((project: any) => ({
|
projects: listProjects(db)
|
||||||
...project,
|
.filter((project: any) => projectVisibleForLocations(project, locations))
|
||||||
status: composeProjectDisplayStatus(
|
.map((project: any) => ({
|
||||||
activeRunStatuses.get(project.id) ??
|
...project,
|
||||||
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
|
status: composeProjectDisplayStatus(
|
||||||
awaitingInputProjects,
|
activeRunStatuses.get(project.id) ??
|
||||||
project.id,
|
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
|
||||||
),
|
awaitingInputProjects,
|
||||||
})),
|
project.id,
|
||||||
|
),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
res.json(body);
|
res.json(body);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -250,9 +455,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
|
|
||||||
app.post('/api/projects', async (req, res) => {
|
app.post('/api/projects', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
|
const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
|
||||||
req.body || {};
|
req.body || {};
|
||||||
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
|
if (typeof id !== 'string' || !isSafeId(id)) {
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
||||||
}
|
}
|
||||||
if (typeof name !== 'string' || !name.trim()) {
|
if (typeof name !== 'string' || !name.trim()) {
|
||||||
|
|
@ -306,11 +511,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
|
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
|
||||||
}
|
}
|
||||||
const normalizedSkillId = skillValidation.id;
|
const normalizedSkillId = skillValidation.id;
|
||||||
|
const selectedLocationId = await resolveCreateProjectLocationId(projectLocationId);
|
||||||
|
let externalProjectDir: string | null = null;
|
||||||
|
if (selectedLocationId !== BUILT_IN_PROJECT_LOCATION_ID) {
|
||||||
|
const location = (await configuredProjectLocations()).find((loc: any) => loc.id === selectedLocationId);
|
||||||
|
if (!location || location.builtIn) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', 'unknown project location');
|
||||||
|
}
|
||||||
|
if (getProject(db, id)) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', 'project id already exists');
|
||||||
|
}
|
||||||
|
externalProjectDir = await createLocationProjectDir(location, id);
|
||||||
|
}
|
||||||
const projectMetadata =
|
const projectMetadata =
|
||||||
metadata && typeof metadata === 'object'
|
metadata && typeof metadata === 'object'
|
||||||
? {
|
? {
|
||||||
...metadata,
|
...metadata,
|
||||||
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
|
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
|
||||||
|
...(externalProjectDir
|
||||||
|
? {
|
||||||
|
baseDir: externalProjectDir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: selectedLocationId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(Array.isArray(metadata.linkedDirs)
|
...(Array.isArray(metadata.linkedDirs)
|
||||||
? (() => {
|
? (() => {
|
||||||
const v = validateLinkedDirs(metadata.linkedDirs);
|
const v = validateLinkedDirs(metadata.linkedDirs);
|
||||||
|
|
@ -319,23 +543,58 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
: skipDiscoveryBrief === true
|
: skipDiscoveryBrief === true
|
||||||
? { skipDiscoveryBrief: true }
|
? {
|
||||||
: null;
|
skipDiscoveryBrief: true,
|
||||||
|
...(externalProjectDir
|
||||||
|
? {
|
||||||
|
baseDir: externalProjectDir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: selectedLocationId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: externalProjectDir
|
||||||
|
? {
|
||||||
|
kind: 'prototype',
|
||||||
|
baseDir: externalProjectDir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: selectedLocationId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const project = insertProject(db, {
|
let project;
|
||||||
id,
|
try {
|
||||||
name: name.trim(),
|
if (externalProjectDir) {
|
||||||
skillId: normalizedSkillId,
|
await writeProjectManifest(externalProjectDir, {
|
||||||
designSystemId: normalizedDesignSystemId,
|
schemaVersion: 1,
|
||||||
pendingPrompt: pendingPrompt || null,
|
id,
|
||||||
metadata: projectMetadata,
|
name: name.trim(),
|
||||||
customInstructions:
|
createdAt: now,
|
||||||
typeof customInstructions === 'string'
|
updatedAt: now,
|
||||||
? customInstructions
|
skillId: normalizedSkillId,
|
||||||
: null,
|
designSystemId: normalizedDesignSystemId,
|
||||||
createdAt: now,
|
});
|
||||||
updatedAt: now,
|
}
|
||||||
});
|
project = insertProject(db, {
|
||||||
|
id,
|
||||||
|
name: name.trim(),
|
||||||
|
skillId: normalizedSkillId,
|
||||||
|
designSystemId: normalizedDesignSystemId,
|
||||||
|
pendingPrompt: pendingPrompt || null,
|
||||||
|
metadata: projectMetadata,
|
||||||
|
customInstructions:
|
||||||
|
typeof customInstructions === 'string'
|
||||||
|
? customInstructions
|
||||||
|
: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (externalProjectDir) {
|
||||||
|
await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
// Seed a default conversation so the UI always has somewhere to write.
|
// Seed a default conversation so the UI always has somewhere to write.
|
||||||
const cid = randomId();
|
const cid = randomId();
|
||||||
insertConversation(db, {
|
insertConversation(db, {
|
||||||
|
|
@ -345,7 +604,6 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
const explicitPlugin =
|
const explicitPlugin =
|
||||||
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
|
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
|
||||||
? true
|
? true
|
||||||
|
|
@ -398,7 +656,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
) {
|
) {
|
||||||
const tpl = getTemplate(db, metadata.templateId);
|
const tpl = getTemplate(db, metadata.templateId);
|
||||||
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
|
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
|
||||||
await ensureProject(PROJECTS_DIR, id);
|
await ensureProject(PROJECTS_DIR, id, projectMetadata);
|
||||||
for (const f of tpl.files) {
|
for (const f of tpl.files) {
|
||||||
if (
|
if (
|
||||||
!f ||
|
!f ||
|
||||||
|
|
@ -413,6 +671,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
id,
|
id,
|
||||||
f.name,
|
f.name,
|
||||||
Buffer.from(f.content, 'utf8'),
|
Buffer.from(f.content, 'utf8'),
|
||||||
|
{},
|
||||||
|
projectMetadata,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Skip individual file failures — the template snapshot is
|
// Skip individual file failures — the template snapshot is
|
||||||
|
|
@ -435,9 +695,10 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/projects/:id', (req, res) => {
|
app.get('/api/projects/:id', async (req, res) => {
|
||||||
const project = getProject(db, req.params.id);
|
const project = getProject(db, req.params.id);
|
||||||
if (!project)
|
const locations = await configuredProjectLocations();
|
||||||
|
if (!project || !projectVisibleForLocations(project, locations))
|
||||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
||||||
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
|
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
|
||||||
/** @type {import('@open-design/contracts').ProjectResponse} */
|
/** @type {import('@open-design/contracts').ProjectResponse} */
|
||||||
|
|
@ -484,6 +745,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
...(existingMeta.importedFrom === 'folder'
|
...(existingMeta.importedFrom === 'folder'
|
||||||
? { importedFrom: 'folder' }
|
? { importedFrom: 'folder' }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(existingMeta.importedFrom === 'project-location'
|
||||||
|
? { importedFrom: 'project-location' }
|
||||||
|
: {}),
|
||||||
|
...(typeof existingMeta.projectLocationId === 'string'
|
||||||
|
? { projectLocationId: existingMeta.projectLocationId }
|
||||||
|
: {}),
|
||||||
...(existingMeta.fromTrustedPicker === true
|
...(existingMeta.fromTrustedPicker === true
|
||||||
? { fromTrustedPicker: true as const }
|
? { fromTrustedPicker: true as const }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -942,6 +1209,104 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||||
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
|
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
|
||||||
const { buildDocumentPreview } = ctx.documents;
|
const { buildDocumentPreview } = ctx.documents;
|
||||||
const { validateArtifactManifestInput } = ctx.artifacts;
|
const { validateArtifactManifestInput } = ctx.artifacts;
|
||||||
|
const projectPreviewIframeSandbox = 'allow-scripts allow-forms';
|
||||||
|
const projectPreviewCsp = [
|
||||||
|
`sandbox ${projectPreviewIframeSandbox}`,
|
||||||
|
"default-src 'self' data: blob:",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"media-src 'self' data: blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
].join('; ');
|
||||||
|
const previewScopeRe = /^[A-Za-z0-9_-]{8,128}$/u;
|
||||||
|
|
||||||
|
function setProjectPreviewHeaders(res: Response) {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('Content-Security-Policy', projectPreviewCsp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendProjectFile(
|
||||||
|
req: any,
|
||||||
|
res: Response,
|
||||||
|
projectId: string,
|
||||||
|
relPath: string,
|
||||||
|
metadata?: unknown,
|
||||||
|
beforeSend?: (mime: string) => void,
|
||||||
|
transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string,
|
||||||
|
) {
|
||||||
|
const meta = await resolveProjectFilePath(
|
||||||
|
PROJECTS_DIR,
|
||||||
|
projectId,
|
||||||
|
relPath,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
beforeSend?.(meta.mime);
|
||||||
|
|
||||||
|
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
|
||||||
|
res.setHeader('Accept-Ranges', 'bytes');
|
||||||
|
res.setHeader('Content-Type', meta.mime);
|
||||||
|
|
||||||
|
if (meta.size === 0) {
|
||||||
|
res.setHeader('Content-Length', '0');
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = parseByteRange(req.headers.range, meta.size);
|
||||||
|
|
||||||
|
if (range === 'unsatisfiable') {
|
||||||
|
res.setHeader('Content-Range', `bytes */${meta.size}`);
|
||||||
|
return res.status(416).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
let statusCode;
|
||||||
|
if (range) {
|
||||||
|
({ start, end } = range);
|
||||||
|
statusCode = 206;
|
||||||
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
|
||||||
|
res.setHeader('Content-Length', String(end - start + 1));
|
||||||
|
} else {
|
||||||
|
start = 0;
|
||||||
|
end = meta.size - 1;
|
||||||
|
statusCode = 200;
|
||||||
|
res.setHeader('Content-Length', String(meta.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode);
|
||||||
|
const stream = fs.createReadStream(meta.filePath, { start, end });
|
||||||
|
stream.on('error', (streamErr: any) => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
|
||||||
|
} else {
|
||||||
|
res.destroy(streamErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.pipe(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, metadata);
|
||||||
|
res.type(file.mime).send(transformFile ? transformFile(file) : file.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewFilePathForProject(project: any, queryFile: unknown): string {
|
||||||
|
if (typeof queryFile === 'string' && queryFile.trim().length > 0) {
|
||||||
|
return queryFile;
|
||||||
|
}
|
||||||
|
const entryFile = project?.metadata?.entryFile;
|
||||||
|
return typeof entryFile === 'string' && entryFile.length > 0 ? entryFile : 'index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeProjectPathForUrl(filePath: string): string {
|
||||||
|
return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
// Project files. Each project owns a flat folder under .od/projects/<id>/
|
// Project files. Each project owns a flat folder under .od/projects/<id>/
|
||||||
// containing every file the user has uploaded, pasted, sketched, or that
|
// containing every file the user has uploaded, pasted, sketched, or that
|
||||||
|
|
@ -1000,6 +1365,79 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/preview-url', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = getProject(db, req.params.id);
|
||||||
|
if (!project) {
|
||||||
|
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestedPath = previewFilePathForProject(project, req.query.file);
|
||||||
|
const meta = await resolveProjectFilePath(
|
||||||
|
PROJECTS_DIR,
|
||||||
|
project.id,
|
||||||
|
requestedPath,
|
||||||
|
project.metadata,
|
||||||
|
);
|
||||||
|
const scope = randomUUID();
|
||||||
|
/** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */
|
||||||
|
const body = {
|
||||||
|
url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`,
|
||||||
|
file: meta.name,
|
||||||
|
csp: projectPreviewCsp,
|
||||||
|
iframeSandbox: projectPreviewIframeSandbox,
|
||||||
|
opaqueOrigin: true,
|
||||||
|
};
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||||
|
sendApiError(
|
||||||
|
res,
|
||||||
|
status,
|
||||||
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||||
|
String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(/^\/api\/projects\/([^/]+)\/preview\/([^/]+)\/(.+)$/u, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const params = req.params as unknown as { 0?: string; 1?: string; 2?: string };
|
||||||
|
const projectId = String(params[0] ?? '');
|
||||||
|
const scope = String(params[1] ?? '');
|
||||||
|
const relPath = String(params[2] ?? '');
|
||||||
|
if (!previewScopeRe.test(scope)) {
|
||||||
|
sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project = getProject(db, projectId);
|
||||||
|
if (!project) {
|
||||||
|
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.headers.origin === 'null') {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
}
|
||||||
|
await sendProjectFile(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
project.id,
|
||||||
|
relPath,
|
||||||
|
project.metadata,
|
||||||
|
() => setProjectPreviewHeaders(res),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||||
|
sendApiError(
|
||||||
|
res,
|
||||||
|
status,
|
||||||
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||||
|
String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Preflight for the raw file route. Current artifact fetches are simple GETs
|
// Preflight for the raw file route. Current artifact fetches are simple GETs
|
||||||
// (no preflight needed), but an explicit handler future-proofs the route if
|
// (no preflight needed), but an explicit handler future-proofs the route if
|
||||||
|
|
@ -1027,66 +1465,23 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = await resolveProjectFilePath(
|
await sendProjectFile(
|
||||||
PROJECTS_DIR,
|
req,
|
||||||
|
res,
|
||||||
projectId,
|
projectId,
|
||||||
relPath,
|
relPath,
|
||||||
project?.metadata,
|
project?.metadata,
|
||||||
);
|
undefined,
|
||||||
|
(file) => {
|
||||||
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
|
if (
|
||||||
res.setHeader('Accept-Ranges', 'bytes');
|
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
||||||
res.setHeader('Content-Type', meta.mime);
|
/^text\/html(?:;|$)/i.test(file.mime)
|
||||||
|
) {
|
||||||
if (meta.size === 0) {
|
return injectUrlPreviewScrollBridge(file.buffer.toString('utf8'));
|
||||||
res.setHeader('Content-Length', '0');
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = parseByteRange(req.headers.range, meta.size);
|
|
||||||
|
|
||||||
if (range === 'unsatisfiable') {
|
|
||||||
res.setHeader('Content-Range', `bytes */${meta.size}`);
|
|
||||||
return res.status(416).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
let start;
|
|
||||||
let end;
|
|
||||||
let statusCode;
|
|
||||||
if (range) {
|
|
||||||
({ start, end } = range);
|
|
||||||
statusCode = 206;
|
|
||||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
|
|
||||||
res.setHeader('Content-Length', String(end - start + 1));
|
|
||||||
} else {
|
|
||||||
start = 0;
|
|
||||||
end = meta.size - 1;
|
|
||||||
statusCode = 200;
|
|
||||||
res.setHeader('Content-Length', String(meta.size));
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(statusCode);
|
|
||||||
const stream = fs.createReadStream(meta.filePath, { start, end });
|
|
||||||
stream.on('error', (streamErr: any) => {
|
|
||||||
if (!res.headersSent) {
|
|
||||||
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
|
|
||||||
} else {
|
|
||||||
res.destroy(streamErr);
|
|
||||||
}
|
}
|
||||||
});
|
return file.buffer;
|
||||||
stream.pipe(res);
|
},
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
|
|
||||||
if (
|
|
||||||
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
|
||||||
/^text\/html(?:;|$)/i.test(file.mime)
|
|
||||||
) {
|
|
||||||
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.type(file.mime).send(file.buffer);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||||
sendApiError(
|
sendApiError(
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,16 @@ const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
|
||||||
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
|
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
|
||||||
const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md';
|
const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md';
|
||||||
const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json';
|
const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json';
|
||||||
|
export const RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS = 1000;
|
||||||
export const projectFileRenameTestHooks = {
|
export const projectFileRenameTestHooks = {
|
||||||
beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise<void> | void),
|
beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise<void> | void),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isRunTouchedProjectFile(fileMtimeMs, runStartTimeMs) {
|
||||||
|
if (!Number.isFinite(fileMtimeMs) || !Number.isFinite(runStartTimeMs)) return false;
|
||||||
|
return fileMtimeMs + RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS >= runStartTimeMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function projectDir(projectsRoot, projectId) {
|
export function projectDir(projectsRoot, projectId) {
|
||||||
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||||
return path.join(projectsRoot, projectId);
|
return path.join(projectsRoot, projectId);
|
||||||
|
|
@ -626,7 +632,8 @@ export async function resolveProjectFilePath(projectsRoot, projectId, name, meta
|
||||||
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
|
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
|
||||||
const file = await resolveSafeReal(dir, name);
|
const file = await resolveSafeReal(dir, name);
|
||||||
const st = await stat(file);
|
const st = await stat(file);
|
||||||
const rel = toProjectPath(path.relative(dir, file));
|
const rootReal = await realpath(dir).catch(() => dir);
|
||||||
|
const rel = toProjectPath(path.relative(rootReal, file));
|
||||||
return {
|
return {
|
||||||
filePath: file,
|
filePath: file,
|
||||||
name: rel,
|
name: rel,
|
||||||
|
|
@ -880,6 +887,7 @@ export async function renameProjectFile(projectsRoot, projectId, fromName, toNam
|
||||||
await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath });
|
await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath });
|
||||||
await renameFilePath(source, targetPath, { noOverwrite: true });
|
await renameFilePath(source, targetPath, { noOverwrite: true });
|
||||||
await commitArtifactManifestRename(manifestRename, newName);
|
await commitArtifactManifestRename(manifestRename, newName);
|
||||||
|
await updateArtifactManifestRefsForRename(dir, oldName, newName);
|
||||||
|
|
||||||
const st = await stat(targetPath);
|
const st = await stat(targetPath);
|
||||||
const manifest = await readManifestForPath(dir, newName);
|
const manifest = await readManifestForPath(dir, newName);
|
||||||
|
|
@ -974,16 +982,22 @@ async function prepareArtifactManifestRename(dir, oldName, newName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { oldManifestPath, newManifestPath: targetManifestPath, raw };
|
return { oldManifestPath, newManifestPath: targetManifestPath, raw, oldName };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commitArtifactManifestRename(manifestRename, newName) {
|
async function commitArtifactManifestRename(manifestRename, newName) {
|
||||||
if (!manifestRename) return;
|
if (!manifestRename) return;
|
||||||
const { oldManifestPath, newManifestPath, raw } = manifestRename;
|
const { oldManifestPath, newManifestPath, raw, oldName } = manifestRename;
|
||||||
await mkdir(path.dirname(newManifestPath), { recursive: true });
|
await mkdir(path.dirname(newManifestPath), { recursive: true });
|
||||||
const parsed = parseManifest(raw);
|
const parsed = parseManifest(raw);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const validated = validateArtifactManifestInput(parsed, newName);
|
const parsedEntry = typeof parsed.entry === 'string'
|
||||||
|
? parsed.entry.replace(/\\/g, '/')
|
||||||
|
: '';
|
||||||
|
const renamedManifest = parsedEntry === oldName
|
||||||
|
? { ...parsed, entry: newName }
|
||||||
|
: parsed;
|
||||||
|
const validated = validateArtifactManifestInput(renamedManifest, newName);
|
||||||
if (validated.ok && validated.value) {
|
if (validated.ok && validated.value) {
|
||||||
await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2));
|
await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2));
|
||||||
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
|
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
|
||||||
|
|
@ -993,6 +1007,153 @@ async function commitArtifactManifestRename(manifestRename, newName) {
|
||||||
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
|
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateArtifactManifestRefsForRename(dir, oldName, newName) {
|
||||||
|
const manifests = [];
|
||||||
|
await collectArtifactManifestFiles(dir, '', manifests);
|
||||||
|
for (const manifestFile of manifests) {
|
||||||
|
const ownerName = ownerNameForArtifactManifest(manifestFile.relPath);
|
||||||
|
if (!ownerName) continue;
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = await readFile(manifestFile.fullPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'ENOENT') continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const parsed = parseManifest(raw);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
|
const updated = rewriteArtifactManifestRenameRefs(parsed, {
|
||||||
|
ownerName,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
});
|
||||||
|
if (!updated.changed) continue;
|
||||||
|
|
||||||
|
const validated = validateArtifactManifestInput(updated.manifest, ownerName);
|
||||||
|
if (!validated.ok || !validated.value) continue;
|
||||||
|
await writeFile(manifestFile.fullPath, JSON.stringify(validated.value, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectArtifactManifestFiles(dir, relDir, out) {
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'ENOENT') return;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await collectArtifactManifestFiles(fullPath, relPath, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isFile() && entry.name.endsWith('.artifact.json')) {
|
||||||
|
out.push({ relPath, fullPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerNameForArtifactManifest(manifestName) {
|
||||||
|
const suffix = '.artifact.json';
|
||||||
|
if (!manifestName.endsWith(suffix)) return null;
|
||||||
|
return manifestName.slice(0, -suffix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteArtifactManifestRenameRefs(manifest, { ownerName, oldName, newName }) {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...manifest };
|
||||||
|
|
||||||
|
const entry = rewriteManifestRefForRename(next.entry, ownerName, oldName, newName, {
|
||||||
|
preferProjectRoot: true,
|
||||||
|
});
|
||||||
|
if (entry.changed) {
|
||||||
|
next.entry = entry.value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof next.primary === 'string') {
|
||||||
|
const primary = rewriteManifestRefForRename(next.primary, ownerName, oldName, newName, {
|
||||||
|
preferProjectRoot: true,
|
||||||
|
});
|
||||||
|
if (primary.changed) {
|
||||||
|
next.primary = primary.value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(next.supportingFiles)) {
|
||||||
|
const supportingFiles = next.supportingFiles.map((ref) => {
|
||||||
|
const updated = rewriteManifestRefForRename(ref, ownerName, oldName, newName);
|
||||||
|
if (updated.changed) changed = true;
|
||||||
|
return updated.value;
|
||||||
|
});
|
||||||
|
if (changed) next.supportingFiles = supportingFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { changed, manifest: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteManifestRefForRename(
|
||||||
|
ref,
|
||||||
|
ownerName,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
options = {},
|
||||||
|
) {
|
||||||
|
if (typeof ref !== 'string') return { changed: false, value: ref };
|
||||||
|
const normalized = ref.replace(/\\/g, '/').trim();
|
||||||
|
if (!normalized) return { changed: false, value: ref };
|
||||||
|
|
||||||
|
if (options.preferProjectRoot && normalizeManifestProjectRootRef(normalized) === oldName) {
|
||||||
|
return { changed: true, value: newName };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeManifestProjectRef(normalized, ownerName) === oldName) {
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
value: relativeManifestRefForOwner(ownerName, newName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === oldName) {
|
||||||
|
return { changed: true, value: newName };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { changed: false, value: ref };
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeManifestRefForOwner(ownerName, targetName) {
|
||||||
|
const ownerDir = path.posix.dirname(ownerName);
|
||||||
|
if (ownerDir === '.') return targetName;
|
||||||
|
const relative = path.posix.relative(ownerDir, targetName);
|
||||||
|
if (!relative || relative === '.' || relative.startsWith('../') || relative.includes('/../')) {
|
||||||
|
return targetName;
|
||||||
|
}
|
||||||
|
return relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManifestProjectRootRef(ref) {
|
||||||
|
return normalizeManifestProjectRef(ref, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManifestProjectRef(ref, ownerName) {
|
||||||
|
if (typeof ref !== 'string' || !ref.trim()) return null;
|
||||||
|
const value = ref.trim().replace(/\\/g, '/');
|
||||||
|
if (value.includes('\0') || value.startsWith('/')) return null;
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
||||||
|
const ownerDir = path.posix.dirname(ownerName);
|
||||||
|
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
|
||||||
|
const normalized = path.posix.normalize(joined).replace(/^\.\//, '');
|
||||||
|
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
|
||||||
|
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeProjectDir(projectsRoot, projectId) {
|
export async function removeProjectDir(projectsRoot, projectId) {
|
||||||
const dir = projectDir(projectsRoot, projectId);
|
const dir = projectDir(projectsRoot, projectId);
|
||||||
await rm(dir, { recursive: true, force: true });
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
|
|
||||||
|
|
@ -243,16 +243,18 @@ reported that exact condition. One failed dispatcher call is enough to
|
||||||
report the error; do not fan out into alternate execution paths inside
|
report the error; do not fan out into alternate execution paths inside
|
||||||
the same turn.
|
the same turn.
|
||||||
|
|
||||||
### Long-running renders (Volcengine i2v, hyperframes-html): generate → wait loop
|
### All slow renders: generate → wait loop
|
||||||
|
|
||||||
\`media generate\` no longer blocks for the full render. It dispatches
|
Any model whose generation takes longer than ~25s — including **fal flux-pro-ultra,
|
||||||
the task daemon-side and either returns the finished \`{"file":{...}}\`
|
fal Veo, fal Sora, Volcengine i2v, hyperframes-html, and anything else with a
|
||||||
or returns a successful queued/running handoff with \`{taskId}\`. You then
|
multi-minute pipeline** — will not complete within the initial \`media generate\` call.
|
||||||
drive the render to completion by calling \`media wait <taskId>\` through \`OD_NODE_BIN\` + \`OD_BIN\` in
|
|
||||||
a loop — each call long-polls the daemon for up to 25s, well below your
|
\`media generate\` dispatches the task daemon-side and polls for up to ~25s. It
|
||||||
shell tool's default 30s timeout. \`media generate\` treats the handoff as
|
always exits 0 — either with \`{"file":{...}}\` if the render finished within that
|
||||||
exit \`0\` so the first dispatch does not look like a failed shell call.
|
window, or with \`{"taskId":"..."}\` as a handoff signal. You then drive the render
|
||||||
The wait subcommand exits with a distinct code per outcome:
|
to completion by calling \`media wait <taskId>\` through \`OD_NODE_BIN\` + \`OD_BIN\`
|
||||||
|
in a loop — each call long-polls the daemon for up to 120s. The wait subcommand
|
||||||
|
exits with a distinct code per outcome:
|
||||||
|
|
||||||
- \`exit 0\` — terminal **done**. Final stdout line is \`{"file":{...}}\`.
|
- \`exit 0\` — terminal **done**. Final stdout line is \`{"file":{...}}\`.
|
||||||
- \`exit 5\` — terminal **failed**. Stderr carries the upstream error.
|
- \`exit 5\` — terminal **failed**. Stderr carries the upstream error.
|
||||||
|
|
@ -262,33 +264,43 @@ The wait subcommand exits with a distinct code per outcome:
|
||||||
off (\`--since\` skips already-seen progress lines so you don't see the
|
off (\`--since\` skips already-seen progress lines so you don't see the
|
||||||
same chatter twice).
|
same chatter twice).
|
||||||
|
|
||||||
The pattern in your shell tool:
|
The pattern in your shell tool (uses python3 to parse JSON — do NOT use jq, it
|
||||||
|
may not be installed):
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
out=$("$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model … --image …)
|
out=\$("$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model flux-pro-ultra --prompt "…")
|
||||||
ec=$?
|
ec=\$?
|
||||||
if [ "$ec" -ne 0 ]; then
|
if [ "\$ec" -ne 0 ]; then
|
||||||
echo "$out" >&2; exit "$ec"
|
echo "\$out" >&2; exit "\$ec"
|
||||||
fi
|
fi
|
||||||
task_id=$(printf '%s\\n' "$out" | tail -1 | jq -r '.taskId // empty')
|
last=\$(printf '%s\\n' "\$out" | tail -1)
|
||||||
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // 0')
|
task_id=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('taskId',''))" 2>/dev/null)
|
||||||
while [ -n "$task_id" ]; do
|
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',0))" 2>/dev/null)
|
||||||
out=$("$OD_NODE_BIN" "$OD_BIN" media wait "$task_id" --since "$since")
|
since="\${since:-0}"
|
||||||
ec=$?
|
while [ -n "\$task_id" ]; do
|
||||||
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // '"$since")
|
out=\$("$OD_NODE_BIN" "$OD_BIN" media wait "\$task_id" --since "\$since")
|
||||||
if [ "$ec" -eq 0 ]; then
|
ec=\$?
|
||||||
|
last=\$(printf '%s\\n' "\$out" | tail -1)
|
||||||
|
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',\$since))" 2>/dev/null)
|
||||||
|
since="\${since:-0}"
|
||||||
|
if [ "\$ec" -eq 0 ]; then
|
||||||
task_id=""
|
task_id=""
|
||||||
elif [ "$ec" -ne 2 ]; then
|
elif [ "\$ec" -ne 2 ]; then
|
||||||
echo "$out" >&2; exit "$ec"
|
echo "\$out" >&2; exit "\$ec"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
# At this point ec is 0 (done). Final result on the last stdout line of \`out\`.
|
# At this point ec is 0 (done) or 5 (failed). Final result on the last stdout line of \$out.
|
||||||
|
printf '%s\\n' "\$last"
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Each \`generate\` and \`wait\` call lasts at most ~25s, so the agent
|
Each \`generate\` call lasts at most ~25s and each \`wait\` call at most ~120s,
|
||||||
shell tool's default ~30s cap never fires. Progress lines stream to
|
both well within your shell tool's timeout. Progress lines stream to stderr as
|
||||||
stderr as they arrive, so the user sees live status in chat throughout
|
they arrive, so the user sees live status in chat throughout the loop instead of
|
||||||
the loop instead of waiting silently for a single multi-minute call.
|
waiting silently for a single multi-minute call.
|
||||||
|
|
||||||
|
**Always write your shell invocation as the full generate+wait loop above**, even
|
||||||
|
for image models. \`flux-pro-ultra\` routinely takes 60–180s; \`sora-2\` and
|
||||||
|
\`veo-3-fal\` take longer. In the wait loop, exit 2 means "keep polling, not an error."
|
||||||
|
|
||||||
A note on \`fetch failed\` to \`127.0.0.1\`. The OD daemon runs on
|
A note on \`fetch failed\` to \`127.0.0.1\`. The OD daemon runs on
|
||||||
loopback in the same machine that spawned you, so it is essentially
|
loopback in the same machine that spawned you, so it is essentially
|
||||||
|
|
@ -318,10 +330,19 @@ showed it crashed).
|
||||||
- **audio · speech**: ${AUDIO_SPEECH_IDS}
|
- **audio · speech**: ${AUDIO_SPEECH_IDS}
|
||||||
- **audio · sfx**: ${AUDIO_SFX_IDS}
|
- **audio · sfx**: ${AUDIO_SFX_IDS}
|
||||||
|
|
||||||
If the user requests a model that is not in this list, surface a warning
|
If the user requests a model that is not in this list **and** the ID does
|
||||||
in your reply and either (a) ask them to pick a registered ID or (b)
|
not start with \`fal-ai/\`, surface a warning in your reply and either
|
||||||
proceed with the project metadata's default model and explain the
|
(a) ask them to pick a registered ID or (b) proceed with the project
|
||||||
substitution. Do not silently fall back.
|
metadata's default model and explain the substitution. Do not silently
|
||||||
|
fall back.
|
||||||
|
|
||||||
|
Exception — **fal-ai/\* custom paths**: any model ID that begins with
|
||||||
|
\`fal-ai/\` (e.g. \`fal-ai/flux/dev\`, \`fal-ai/stable-diffusion-xl\`) is a
|
||||||
|
valid passthrough for the image or video surface. Pass it to
|
||||||
|
\`"$OD_NODE_BIN" "$OD_BIN" media generate\` as-is via \`--model <id>\`;
|
||||||
|
the daemon routes it directly to the fal queue without a catalog entry.
|
||||||
|
Do **not** warn the user or substitute the default when a \`fal-ai/\`
|
||||||
|
path is given.
|
||||||
|
|
||||||
### Workflow rules
|
### Workflow rules
|
||||||
|
|
||||||
|
|
@ -344,22 +365,47 @@ substitution. Do not silently fall back.
|
||||||
SFX duration is capped at 30 seconds by the provider.
|
SFX duration is capped at 30 seconds by the provider.
|
||||||
\`language\` enables pronunciation boost for specific languages
|
\`language\` enables pronunciation boost for specific languages
|
||||||
(e.g. \`Chinese,Yue\` for Cantonese, \`Chinese\` for Mandarin).
|
(e.g. \`Chinese,Yue\` for Cantonese, \`Chinese\` for Mandarin).
|
||||||
2. **One discovery turn before generating.** Even with metadata defaults
|
2. **Dispatch immediately when the brief is complete.** For image and video
|
||||||
present, restate what you're about to make and ask one targeted
|
projects, if the user's prompt specifies the subject, style/mood, and setting,
|
||||||
question if anything is ambiguous (subject, mood, brand, voice). The
|
**dispatch without a discovery question turn**. Do not ask about model or aspect
|
||||||
discovery rules from the philosophy layer still apply — emit a
|
ratio when reasonable defaults exist — use them and start generating.
|
||||||
question form on turn 1 unless the user's prompt already pins every
|
|
||||||
variable.
|
Default model selection (use these when \`imageModel\`/\`videoModel\` is unknown
|
||||||
|
or the user asks for "best"):
|
||||||
|
- **Image, best quality (user says "best", "highest quality", "most realistic")**:
|
||||||
|
use \`flux-pro-ultra\` — but tell the user it takes 60–180s
|
||||||
|
- **Image, default / no preference stated**: use the project metadata's
|
||||||
|
\`imageModel\` if set; otherwise use \`gpt-image-2\`
|
||||||
|
- **Video, best quality**: use project metadata \`videoModel\` if set; otherwise
|
||||||
|
\`doubao-seedance-2-0-260128\`
|
||||||
|
|
||||||
|
Default aspect ratio (use when \`aspectRatio\` is unknown):
|
||||||
|
- Landscape/outdoor scenes, cinematic, widescreen → \`16:9\`
|
||||||
|
- Portrait, vertical social → \`9:16\`
|
||||||
|
- Product, abstract, square social → \`1:1\`
|
||||||
|
- General default when no cue → \`1:1\`
|
||||||
|
|
||||||
|
**Skip the discovery question when all of these are true:**
|
||||||
|
- The subject is described (what to generate)
|
||||||
|
- The style or mood is implied or stated (realistic, cinematic, illustrated, etc.)
|
||||||
|
- Any model/aspect gaps can be filled with the defaults above
|
||||||
|
|
||||||
|
**Do ask** if the output intent is genuinely ambiguous (e.g. "make something cool"
|
||||||
|
with no subject), or the user explicitly requests a model/voice the project
|
||||||
|
metadata doesn't carry.
|
||||||
|
|
||||||
For \`hyperframes-html\`, the discovery turn is the last turn before
|
For \`hyperframes-html\`, the discovery turn is the last turn before
|
||||||
you start authoring. Once the user answers, write the composition
|
you start authoring. Once the user answers, write the composition
|
||||||
files into \`.hyperframes-cache/\` and run \`npx hyperframes render\`
|
files into \`.hyperframes-cache/\` and run \`npx hyperframes render\`
|
||||||
immediately — do not add a second "plan" or "environment check"
|
immediately — do not add a second "plan" or "environment check"
|
||||||
message first, and do not call \`"$OD_NODE_BIN" "$OD_BIN" media generate\` (that path is
|
message first, and do not call \`"$OD_NODE_BIN" "$OD_BIN" media generate\` (that path is
|
||||||
intentionally rejected for this model).
|
intentionally rejected for this model).
|
||||||
3. **Generate by shell, narrate in chat.** When you actually invoke
|
3. **Generate by shell, reply in one short message.** When you invoke
|
||||||
\`"$OD_NODE_BIN" "$OD_BIN" media generate\`, do it inside a clearly-labelled tool call. After
|
\`"$OD_NODE_BIN" "$OD_BIN" media generate\`, do it inside a clearly-labelled tool call.
|
||||||
it returns, write a short reply: what was produced, the filename,
|
After the command completes, reply with **one brief message** (2–3 sentences max):
|
||||||
and any notes (model substitutions, retries, follow-up suggestions).
|
the filename, the model used, and a single follow-up offer ("Want a different
|
||||||
|
aspect ratio?" / "Try again with more fog?"). Do not write long descriptions,
|
||||||
|
artistic analyses, or multi-paragraph commentary. Speed matters.
|
||||||
If it fails, quote the real stderr / exit code and stop there.
|
If it fails, quote the real stderr / exit code and stop there.
|
||||||
Never say "I dispatched the render" / "the generation has started"
|
Never say "I dispatched the render" / "the generation has started"
|
||||||
unless the shell command has already been executed.
|
unless the shell command has already been executed.
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,62 @@ export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip
|
||||||
|
|
||||||
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Treat the user's first message and project metadata as the brief, then proceed directly to planning/building under the normal artifact workflow. Ask at most one concise follow-up only if a required detail is impossible to infer safely.`;
|
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Treat the user's first message and project metadata as the brief, then proceed directly to planning/building under the normal artifact workflow. Ask at most one concise follow-up only if a required detail is impossible to infer safely.`;
|
||||||
|
|
||||||
|
// Injected into non-media projects so the agent knows how to dispatch
|
||||||
|
// media generation if the user asks for it mid-session (e.g. "generate an
|
||||||
|
// image with fal"). Without this, agents in prototype/deck projects try to
|
||||||
|
// call provider REST APIs directly and ask the user for keys that the daemon
|
||||||
|
// already holds in .od/media-config.json.
|
||||||
|
const MEDIA_DISPATCH_HINT = `
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Media generation (if asked)
|
||||||
|
|
||||||
|
If the user asks you to generate an image, video, or audio file — regardless of which provider or model they mention (fal, Replicate, OpenAI, etc.) — use the daemon dispatcher via your **Bash tool**. Do NOT call provider REST APIs directly.
|
||||||
|
|
||||||
|
The daemon injects these env vars into your shell (**POSIX bash — not PowerShell**):
|
||||||
|
|
||||||
|
- \`OD_NODE_BIN\` — absolute path to the Node runtime
|
||||||
|
- \`OD_BIN\` — absolute path to the OD CLI script
|
||||||
|
- \`OD_PROJECT_ID\` — the active project id
|
||||||
|
|
||||||
|
**Always use the generate→wait loop below.** \`media generate\` always exits 0 — either with \`{"file":{...}}\` if done within ~25s, or with \`{"taskId":"..."}\` as a handoff for slow models (flux-pro-ultra ~60–180s, veo-3-fal longer). Whenever the output contains a \`taskId\`, keep polling with \`media wait\` until exit 0 (done) or exit 5 (failed).
|
||||||
|
|
||||||
|
Use **POSIX \`$VAR\` syntax** — do NOT translate to PowerShell (\`$env:VAR\`, \`&\` operator). Uses \`python3\` for JSON parsing (do NOT use \`jq\`):
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# POSIX bash — do NOT convert to PowerShell
|
||||||
|
out=\$("$OD_NODE_BIN" "$OD_BIN" media generate \\
|
||||||
|
--project "$OD_PROJECT_ID" \\
|
||||||
|
--surface image \\
|
||||||
|
--model flux-pro-ultra \\
|
||||||
|
--prompt "..." \\
|
||||||
|
--aspect 16:9)
|
||||||
|
ec=\$?
|
||||||
|
if [ "\$ec" -ne 0 ]; then echo "\$out" >&2; exit "\$ec"; fi
|
||||||
|
last=\$(printf '%s\\n' "\$out" | tail -1)
|
||||||
|
task_id=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('taskId',''))" 2>/dev/null)
|
||||||
|
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',0))" 2>/dev/null)
|
||||||
|
since="\${since:-0}"
|
||||||
|
while [ -n "\$task_id" ]; do
|
||||||
|
out=\$("$OD_NODE_BIN" "$OD_BIN" media wait "\$task_id" --since "\$since")
|
||||||
|
ec=\$?
|
||||||
|
last=\$(printf '%s\\n' "\$out" | tail -1)
|
||||||
|
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',\$since))" 2>/dev/null)
|
||||||
|
since="\${since:-0}"
|
||||||
|
if [ "\$ec" -eq 0 ]; then
|
||||||
|
task_id=""
|
||||||
|
elif [ "\$ec" -ne 2 ]; then
|
||||||
|
echo "\$out" >&2; exit "\$ec"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s\\n' "\$last"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**Never ask the user for an API key.** The daemon reads provider credentials from its config; keys are never passed through the shell. If the provider returns an auth error, tell the user to open Settings → AI Providers and confirm the key is configured there.
|
||||||
|
|
||||||
|
For the best fal image model use \`--model flux-pro-ultra\`. For video use \`--model veo-3-fal\` or \`--model wan-2.1-t2v\`. Always pass \`--surface\` explicitly (\`image\`, \`video\`, or \`audio\`). Any \`fal-ai/*\` path (e.g. \`fal-ai/flux/schnell\`, \`fal-ai/wan-i2v\`) is also a valid \`--model\` value for image/video — pass it through as-is without substitution.`;
|
||||||
|
|
||||||
const ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE = `
|
const ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE = `
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -439,6 +495,21 @@ export function composeSystemPrompt({
|
||||||
parts.push('\n\n---\n\n');
|
parts.push('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip the HTML-artifact discovery layer for media surfaces (image / video /
|
||||||
|
// audio). DISCOVERY_AND_PHILOSOPHY is ~3 000 tokens of rules about question
|
||||||
|
// forms, brand extraction, direction pickers, and HTML artifact checklist —
|
||||||
|
// none of which apply to media generation. Including it forces the agent to
|
||||||
|
// parse and override all of those rules before it can start, adding tokens
|
||||||
|
// and LLM inference time. The MEDIA_GENERATION_CONTRACT (pushed below) is
|
||||||
|
// the sole workflow authority for these surfaces.
|
||||||
|
const isMediaSurfaceEarly =
|
||||||
|
skillMode === 'image' ||
|
||||||
|
skillMode === 'video' ||
|
||||||
|
skillMode === 'audio' ||
|
||||||
|
metadata?.kind === 'image' ||
|
||||||
|
metadata?.kind === 'video' ||
|
||||||
|
metadata?.kind === 'audio';
|
||||||
|
|
||||||
if (metadata?.skipDiscoveryBrief === true) {
|
if (metadata?.skipDiscoveryBrief === true) {
|
||||||
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
|
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
|
||||||
parts.push('\n\n---\n\n');
|
parts.push('\n\n---\n\n');
|
||||||
|
|
@ -450,9 +521,12 @@ export function composeSystemPrompt({
|
||||||
parts.push('\n\n---\n\n');
|
parts.push('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isMediaSurfaceEarly) {
|
||||||
|
parts.push(DISCOVERY_AND_PHILOSOPHY, '\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
DISCOVERY_AND_PHILOSOPHY,
|
'# Identity and workflow charter (background)\n\n',
|
||||||
'\n\n---\n\n# Identity and workflow charter (background)\n\n',
|
|
||||||
BASE_SYSTEM_PROMPT,
|
BASE_SYSTEM_PROMPT,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -614,6 +688,11 @@ export function composeSystemPrompt({
|
||||||
|| resolvedExclusiveSurface === 'audio';
|
|| resolvedExclusiveSurface === 'audio';
|
||||||
if (isMediaSurface) {
|
if (isMediaSurface) {
|
||||||
parts.push(renderMediaGenerationContract(mediaExecution));
|
parts.push(renderMediaGenerationContract(mediaExecution));
|
||||||
|
} else {
|
||||||
|
// Non-media projects (prototype, deck, etc.): inject a lightweight hint
|
||||||
|
// so the agent uses `od media generate` if the user asks for an image/video
|
||||||
|
// mid-session, rather than hunting for provider API keys in the environment.
|
||||||
|
parts.push(MEDIA_DISPATCH_HINT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeCodexImagegenOverride && shouldAllowCodexImagegenOverride(metadata, mediaExecution)) {
|
if (includeCodexImagegenOverride && shouldAllowCodexImagegenOverride(metadata, mediaExecution)) {
|
||||||
|
|
@ -959,10 +1038,10 @@ function renderMetadataBlock(
|
||||||
}
|
}
|
||||||
if (metadata.kind === 'image') {
|
if (metadata.kind === 'image') {
|
||||||
lines.push(
|
lines.push(
|
||||||
`- **imageModel**: ${metadata.imageModel ?? '(unknown — ask: which image model to use)'}`,
|
`- **imageModel**: ${metadata.imageModel ?? 'gpt-image-2 (default — override if the user asks for a specific model or provider)'}`,
|
||||||
);
|
);
|
||||||
lines.push(
|
lines.push(
|
||||||
`- **aspectRatio**: ${metadata.imageAspect ?? '(unknown — ask: 1:1, 16:9, 9:16, 4:3, 3:4)'}`,
|
`- **aspectRatio**: ${metadata.imageAspect ?? '1:1 (default — use 16:9 for landscape/outdoor scenes, 9:16 for portrait/vertical)'}`,
|
||||||
);
|
);
|
||||||
if (metadata.imageStyle) {
|
if (metadata.imageStyle) {
|
||||||
lines.push(`- **styleNotes**: ${metadata.imageStyle}`);
|
lines.push(`- **styleNotes**: ${metadata.imageStyle}`);
|
||||||
|
|
|
||||||
185
apps/daemon/src/run-tool-bundle.ts
Normal file
185
apps/daemon/src/run-tool-bundle.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp-config.js';
|
||||||
|
import type { RuntimeAgentDef } from './runtimes/types.js';
|
||||||
|
import { sanitizeMcpConfig, sanitizeMcpServer } from './mcp-config.js';
|
||||||
|
|
||||||
|
export interface RunToolBundle {
|
||||||
|
mcpServers: McpServerConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunToolBundleSummary {
|
||||||
|
mcpServers: Array<{
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
templateId?: string;
|
||||||
|
transport: McpTransport;
|
||||||
|
enabled: boolean;
|
||||||
|
authMode?: McpAuthMode;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalMcpSelection {
|
||||||
|
enabledServers: McpServerConfig[];
|
||||||
|
persistedTokenServerIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunToolBundleParseResult =
|
||||||
|
| { ok: true; bundle: RunToolBundle }
|
||||||
|
| { ok: false; message: string };
|
||||||
|
|
||||||
|
export type RunToolBundleValidationResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; message: string };
|
||||||
|
|
||||||
|
export type RunToolBundleDeliveryTarget =
|
||||||
|
| 'managed-project'
|
||||||
|
| 'external-project'
|
||||||
|
| 'none';
|
||||||
|
|
||||||
|
export interface RunToolBundleValidationOptions {
|
||||||
|
deliveryTarget?: RunToolBundleDeliveryTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunToolBundleAgent = Pick<
|
||||||
|
RuntimeAgentDef,
|
||||||
|
'id' | 'name' | 'externalMcpInjection'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLabel(agent: RunToolBundleAgent): string {
|
||||||
|
return agent.name ? `${agent.name} (${agent.id})` : agent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRunToolBundleForRun(raw: unknown): RunToolBundle {
|
||||||
|
if (!isPlainObject(raw)) return { mcpServers: [] };
|
||||||
|
return {
|
||||||
|
mcpServers: sanitizeMcpConfig({ servers: raw.mcpServers }).servers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRunToolBundleForRequest(raw: unknown): RunToolBundleParseResult {
|
||||||
|
if (raw == null) return { ok: true, bundle: { mcpServers: [] } };
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
return { ok: false, message: 'toolBundle must be an object' };
|
||||||
|
}
|
||||||
|
if (raw.mcpServers == null) return { ok: true, bundle: { mcpServers: [] } };
|
||||||
|
if (!Array.isArray(raw.mcpServers)) {
|
||||||
|
return { ok: false, message: 'toolBundle.mcpServers must be an array' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const servers: McpServerConfig[] = [];
|
||||||
|
for (const [index, entry] of raw.mcpServers.entries()) {
|
||||||
|
const server = sanitizeMcpServer(entry);
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `toolBundle.mcpServers[${index}] is invalid`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (seen.has(server.id)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `toolBundle.mcpServers[${index}] duplicates server id "${server.id}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
seen.add(server.id);
|
||||||
|
servers.push(server);
|
||||||
|
}
|
||||||
|
return { ok: true, bundle: { mcpServers: servers } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRunToolBundle(bundle: RunToolBundle | null | undefined): RunToolBundleSummary {
|
||||||
|
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
|
||||||
|
return {
|
||||||
|
mcpServers: servers.map((server) => ({
|
||||||
|
id: server.id,
|
||||||
|
...(server.label ? { label: server.label } : {}),
|
||||||
|
...(server.templateId ? { templateId: server.templateId } : {}),
|
||||||
|
transport: server.transport,
|
||||||
|
enabled: server.enabled,
|
||||||
|
...(server.authMode ? { authMode: server.authMode } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRunToolBundleForAgent(
|
||||||
|
bundle: RunToolBundle | null | undefined,
|
||||||
|
agent: RunToolBundleAgent | null | undefined,
|
||||||
|
options: RunToolBundleValidationOptions = {},
|
||||||
|
): RunToolBundleValidationResult {
|
||||||
|
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
|
||||||
|
const enabledServers = servers.filter((server) => server.enabled);
|
||||||
|
if (enabledServers.length === 0) return { ok: true };
|
||||||
|
if (!agent) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle requires a supported agentId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.externalMcpInjection === 'claude-mcp-json') {
|
||||||
|
if (options.deliveryTarget && options.deliveryTarget !== 'managed-project') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
`${agentLabel(agent)} receives run-scoped MCP tool bundles through project .mcp.json, ` +
|
||||||
|
'so toolBundle requires a daemon-managed project',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.externalMcpInjection === 'opencode-env-content') {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.externalMcpInjection === 'acp-merge') {
|
||||||
|
const unsupported = servers.findIndex(
|
||||||
|
(server) => server.enabled && server.transport !== 'stdio',
|
||||||
|
);
|
||||||
|
if (unsupported === -1) return { ok: true };
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
`toolBundle.mcpServers[${unsupported}] uses ${servers[unsupported]?.transport} transport, ` +
|
||||||
|
`but ${agentLabel(agent)} only supports stdio run-scoped MCP servers`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `${agentLabel(agent)} does not support run-scoped MCP tool bundles`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExternalMcpServersForRun({
|
||||||
|
persistedServers,
|
||||||
|
runScopedServers,
|
||||||
|
sandboxMode,
|
||||||
|
}: {
|
||||||
|
persistedServers: McpServerConfig[];
|
||||||
|
runScopedServers: McpServerConfig[];
|
||||||
|
sandboxMode: boolean;
|
||||||
|
}): ExternalMcpSelection {
|
||||||
|
const runScopedIds = new Set(runScopedServers.map((server) => server.id));
|
||||||
|
const persistedForRun = sandboxMode ? [] : persistedServers;
|
||||||
|
const byId = new Map<string, McpServerConfig>();
|
||||||
|
|
||||||
|
for (const server of persistedForRun) byId.set(server.id, server);
|
||||||
|
for (const server of runScopedServers) byId.set(server.id, server);
|
||||||
|
|
||||||
|
const persistedTokenServerIds = new Set<string>();
|
||||||
|
for (const server of persistedForRun) {
|
||||||
|
if (!server.enabled) continue;
|
||||||
|
if (runScopedIds.has(server.id)) continue;
|
||||||
|
persistedTokenServerIds.add(server.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabledServers: Array.from(byId.values()).filter((server) => server.enabled),
|
||||||
|
persistedTokenServerIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
|
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
|
||||||
|
import {
|
||||||
|
normalizeRunToolBundleForRun,
|
||||||
|
summarizeRunToolBundle,
|
||||||
|
} from './run-tool-bundle.js';
|
||||||
|
|
||||||
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
|
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
|
||||||
|
|
||||||
|
|
@ -57,6 +61,7 @@ export function createChatRunService({
|
||||||
pluginId:
|
pluginId:
|
||||||
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
|
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
|
||||||
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
|
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
|
||||||
|
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
@ -149,6 +154,7 @@ export function createChatRunService({
|
||||||
errorCode: run.errorCode ?? null,
|
errorCode: run.errorCode ?? null,
|
||||||
eventsLogPath: run.eventsLogPath ?? null,
|
eventsLogPath: run.eventsLogPath ?? null,
|
||||||
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
|
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
|
||||||
|
toolBundle: summarizeRunToolBundle(run.toolBundle),
|
||||||
});
|
});
|
||||||
|
|
||||||
const finish = (run, status, code: number | null = null, signal: string | null = null) => {
|
const finish = (run, status, code: number | null = null, signal: string | null = null) => {
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ async function probe(
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredEnv,
|
configuredEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: launch.selectedPath },
|
||||||
),
|
),
|
||||||
launch,
|
launch,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
} from '../sandbox-mode.js';
|
} from '../sandbox-mode.js';
|
||||||
|
|
||||||
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
||||||
|
type SpawnEnvOptions = {
|
||||||
|
resolvedBin?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
|
@ -51,6 +54,7 @@ export function spawnEnvForAgent(
|
||||||
baseEnv: RuntimeEnvMap,
|
baseEnv: RuntimeEnvMap,
|
||||||
configuredEnv: unknown = {},
|
configuredEnv: unknown = {},
|
||||||
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
||||||
|
options: SpawnEnvOptions = {},
|
||||||
): NodeJS.ProcessEnv {
|
): NodeJS.ProcessEnv {
|
||||||
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
|
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
|
||||||
const env = mergeProxyAwareEnv(
|
const env = mergeProxyAwareEnv(
|
||||||
|
|
@ -75,7 +79,9 @@ export function spawnEnvForAgent(
|
||||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||||
}
|
}
|
||||||
if (agentId === 'claude') {
|
if (agentId === 'claude') {
|
||||||
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
if (!isOpenClaudeExecutable(options.resolvedBin)) {
|
||||||
|
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
||||||
|
}
|
||||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||||
}
|
}
|
||||||
if (agentId === 'codex') {
|
if (agentId === 'codex') {
|
||||||
|
|
@ -88,6 +94,15 @@ export function spawnEnvForAgent(
|
||||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
|
||||||
|
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
|
||||||
|
const base = path
|
||||||
|
.basename(resolvedBin.trim().replace(/\\/g, '/'))
|
||||||
|
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||||
|
.toLowerCase();
|
||||||
|
return base === 'openclaude';
|
||||||
|
}
|
||||||
|
|
||||||
function sandboxRuntimeConfigForBaseEnv(
|
function sandboxRuntimeConfigForBaseEnv(
|
||||||
baseEnv: RuntimeEnvMap,
|
baseEnv: RuntimeEnvMap,
|
||||||
): SandboxRuntimeConfig | null {
|
): SandboxRuntimeConfig | null {
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,11 @@ import {
|
||||||
readMcpConfig,
|
readMcpConfig,
|
||||||
writeMcpConfig,
|
writeMcpConfig,
|
||||||
} from './mcp-config.js';
|
} from './mcp-config.js';
|
||||||
|
import {
|
||||||
|
parseRunToolBundleForRequest,
|
||||||
|
resolveExternalMcpServersForRun,
|
||||||
|
validateRunToolBundleForAgent,
|
||||||
|
} from './run-tool-bundle.js';
|
||||||
import {
|
import {
|
||||||
beginAuth,
|
beginAuth,
|
||||||
exchangeCodeForToken,
|
exchangeCodeForToken,
|
||||||
|
|
@ -348,6 +353,7 @@ import {
|
||||||
assertSandboxProjectRootAvailable,
|
assertSandboxProjectRootAvailable,
|
||||||
detectEntryFile,
|
detectEntryFile,
|
||||||
ensureProject,
|
ensureProject,
|
||||||
|
isRunTouchedProjectFile,
|
||||||
isSafeId,
|
isSafeId,
|
||||||
listFiles,
|
listFiles,
|
||||||
mimeFor,
|
mimeFor,
|
||||||
|
|
@ -4169,7 +4175,7 @@ export async function startServer({
|
||||||
// Routes that serve content to sandboxed iframes (Origin: null) for
|
// Routes that serve content to sandboxed iframes (Origin: null) for
|
||||||
// read-only purposes. All other /api routes reject Origin: null.
|
// read-only purposes. All other /api routes reject Origin: null.
|
||||||
const _NULL_ORIGIN_SAFE_GET_RE =
|
const _NULL_ORIGIN_SAFE_GET_RE =
|
||||||
/^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/;
|
/^\/projects\/[^/]+\/(?:raw|preview)\/|^\/codex-pets\/[^/]+\/spritesheet$/;
|
||||||
|
|
||||||
// Reject cross-origin requests to API endpoints.
|
// Reject cross-origin requests to API endpoints.
|
||||||
// Health/version remain open for monitoring probes.
|
// Health/version remain open for monitoring probes.
|
||||||
|
|
@ -5728,6 +5734,7 @@ export async function startServer({
|
||||||
events: projectEventDeps,
|
events: projectEventDeps,
|
||||||
ids: idDeps,
|
ids: idDeps,
|
||||||
telemetry: { reportFinalizedMessage },
|
telemetry: { reportFinalizedMessage },
|
||||||
|
appConfig: appConfigDeps,
|
||||||
validation: validationDeps,
|
validation: validationDeps,
|
||||||
});
|
});
|
||||||
registerImportRoutes(app, {
|
registerImportRoutes(app, {
|
||||||
|
|
@ -10775,8 +10782,8 @@ export async function startServer({
|
||||||
// doesn't exist yet). Without one we don't pass cwd to spawn — the
|
// doesn't exist yet). Without one we don't pass cwd to spawn — the
|
||||||
// agent then runs in whatever inherited dir, which still lets API
|
// agent then runs in whatever inherited dir, which still lets API
|
||||||
// mode work but loses file-tool addressability.
|
// mode work but loses file-tool addressability.
|
||||||
// For git-linked projects (metadata.baseDir), use that folder directly
|
// Project directory resolution lives in projects.ts so sandbox mode can
|
||||||
// so the agent writes back to the user's original source tree.
|
// consistently reject imported-folder metadata that has no managed copy.
|
||||||
let cwd = null;
|
let cwd = null;
|
||||||
let existingProjectFiles = [];
|
let existingProjectFiles = [];
|
||||||
if (typeof projectId === 'string' && projectId) {
|
if (typeof projectId === 'string' && projectId) {
|
||||||
|
|
@ -10901,57 +10908,71 @@ export async function startServer({
|
||||||
// values further down at .mcp.json write time — see the spawn block
|
// values further down at .mcp.json write time — see the spawn block
|
||||||
// below — instead of re-reading.
|
// below — instead of re-reading.
|
||||||
let externalMcpConfig = { servers: [] };
|
let externalMcpConfig = { servers: [] };
|
||||||
try {
|
if (!SANDBOX_RUNTIME.enabled) {
|
||||||
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
|
try {
|
||||||
} catch (err) {
|
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
|
||||||
console.warn(
|
} catch (err) {
|
||||||
'[mcp-config] read failed:',
|
console.warn(
|
||||||
err && err.message ? err.message : err,
|
'[mcp-config] read failed:',
|
||||||
);
|
err && err.message ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
|
const runScopedMcpServers = Array.isArray(run?.toolBundle?.mcpServers)
|
||||||
|
? run.toolBundle.mcpServers
|
||||||
|
: [];
|
||||||
|
const {
|
||||||
|
enabledServers: enabledExternalMcp,
|
||||||
|
persistedTokenServerIds,
|
||||||
|
} = resolveExternalMcpServersForRun({
|
||||||
|
persistedServers: externalMcpConfig.servers,
|
||||||
|
runScopedServers: runScopedMcpServers,
|
||||||
|
sandboxMode: SANDBOX_RUNTIME.enabled,
|
||||||
|
});
|
||||||
const oauthTokensForSpawn = {};
|
const oauthTokensForSpawn = {};
|
||||||
try {
|
if (persistedTokenServerIds.size > 0) {
|
||||||
const stored = await readAllTokens(RUNTIME_DATA_DIR);
|
try {
|
||||||
for (const [serverId, tok] of Object.entries(stored)) {
|
const stored = await readAllTokens(RUNTIME_DATA_DIR);
|
||||||
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
|
for (const [serverId, tok] of Object.entries(stored)) {
|
||||||
// Default to the persisted access token; null it out if expired so
|
if (!persistedTokenServerIds.has(serverId)) continue;
|
||||||
// we never inject a stale `Authorization: Bearer …` header. The
|
// Default to the persisted access token; null it out if expired so
|
||||||
// model treats a server with a Bearer pinned as connected and
|
// we never inject a stale `Authorization: Bearer …` header. The
|
||||||
// discourages re-auth, which is the worst possible UX when the
|
// model treats a server with a Bearer pinned as connected and
|
||||||
// token is going to 401 every call.
|
// discourages re-auth, which is the worst possible UX when the
|
||||||
let access = isTokenExpired(tok) ? null : tok.accessToken;
|
// token is going to 401 every call.
|
||||||
if (isTokenExpired(tok) && tok.refreshToken) {
|
let access = isTokenExpired(tok) ? null : tok.accessToken;
|
||||||
try {
|
if (isTokenExpired(tok) && tok.refreshToken) {
|
||||||
const refreshed = await refreshAndPersistToken(
|
try {
|
||||||
RUNTIME_DATA_DIR,
|
const refreshed = await refreshAndPersistToken(
|
||||||
serverId,
|
RUNTIME_DATA_DIR,
|
||||||
tok,
|
serverId,
|
||||||
);
|
tok,
|
||||||
if (refreshed) access = refreshed.accessToken;
|
);
|
||||||
} catch (err) {
|
if (refreshed) access = refreshed.accessToken;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'[mcp-oauth] refresh failed for',
|
||||||
|
serverId,
|
||||||
|
err && err.message ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (access) {
|
||||||
|
oauthTokensForSpawn[serverId] = access;
|
||||||
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[mcp-oauth] refresh failed for',
|
'[mcp-oauth] skipping expired token for',
|
||||||
serverId,
|
serverId,
|
||||||
err && err.message ? err.message : err,
|
'— reconnect required',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (access) {
|
} catch (err) {
|
||||||
oauthTokensForSpawn[serverId] = access;
|
console.warn(
|
||||||
} else {
|
'[mcp-tokens] read failed:',
|
||||||
console.warn(
|
err && err.message ? err.message : err,
|
||||||
'[mcp-oauth] skipping expired token for',
|
);
|
||||||
serverId,
|
|
||||||
'— reconnect required',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
'[mcp-tokens] read failed:',
|
|
||||||
err && err.message ? err.message : err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const connectedExternalMcp = enabledExternalMcp
|
const connectedExternalMcp = enabledExternalMcp
|
||||||
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
|
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
|
||||||
|
|
@ -11315,6 +11336,8 @@ export async function startServer({
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredAgentEnv,
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: agentLaunch.selectedPath },
|
||||||
),
|
),
|
||||||
agentLaunch,
|
agentLaunch,
|
||||||
)
|
)
|
||||||
|
|
@ -11697,6 +11720,8 @@ export async function startServer({
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredAgentEnv,
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: agentLaunch.selectedPath },
|
||||||
);
|
);
|
||||||
if (def.id === 'amr') {
|
if (def.id === 'amr') {
|
||||||
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
|
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
|
||||||
|
|
@ -12650,6 +12675,7 @@ export async function startServer({
|
||||||
stderrTail: agentStderrTail,
|
stderrTail: agentStderrTail,
|
||||||
stdoutTail: agentStdoutTail,
|
stdoutTail: agentStdoutTail,
|
||||||
env: spawnedAgentEnv,
|
env: spawnedAgentEnv,
|
||||||
|
resolvedBin: agentLaunch.selectedPath,
|
||||||
});
|
});
|
||||||
// A non-zero exit whose output reads as an auth / quota / upstream
|
// A non-zero exit whose output reads as an auth / quota / upstream
|
||||||
// problem (typical of Claude Code, codex, …) gets the specific code
|
// problem (typical of Claude Code, codex, …) gets the specific code
|
||||||
|
|
@ -12726,7 +12752,7 @@ export async function startServer({
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(dir, f.name);
|
const filePath = path.join(dir, f.name);
|
||||||
const st = await fs.promises.stat(filePath);
|
const st = await fs.promises.stat(filePath);
|
||||||
if (st.mtimeMs < runStartTimeMs) continue;
|
if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue;
|
||||||
await reconcileHtmlArtifactManifest(
|
await reconcileHtmlArtifactManifest(
|
||||||
PROJECTS_DIR,
|
PROJECTS_DIR,
|
||||||
run.projectId,
|
run.projectId,
|
||||||
|
|
@ -12995,14 +13021,33 @@ export async function startServer({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function runToolBundleDeliveryTargetForProject(projectId, metadata) {
|
||||||
|
if (typeof projectId !== 'string' || !projectId || !isSafeId(projectId)) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cwd = resolveProjectDir(PROJECTS_DIR, projectId, metadata, {
|
||||||
|
allowUnavailableSandboxImportedProject: true,
|
||||||
|
});
|
||||||
|
return isManagedProjectCwd(cwd, PROJECTS_DIR) ? 'managed-project' : 'external-project';
|
||||||
|
} catch {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/runs', async (req, res) => {
|
app.post('/api/runs', async (req, res) => {
|
||||||
if (daemonShuttingDown) {
|
if (daemonShuttingDown) {
|
||||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||||
}
|
}
|
||||||
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
|
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
|
||||||
|
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
|
||||||
if (!mediaExecution.ok) {
|
if (!mediaExecution.ok) {
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||||
}
|
}
|
||||||
|
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
|
||||||
|
if (!toolBundle.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
|
||||||
|
}
|
||||||
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
|
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
|
||||||
// before the run is created. The resolver returns null when the body
|
// before the run is created. The resolver returns null when the body
|
||||||
// does not mention a plugin (legacy runs unchanged), an error envelope
|
// does not mention a plugin (legacy runs unchanged), an error envelope
|
||||||
|
|
@ -13018,7 +13063,7 @@ export async function startServer({
|
||||||
// bundled scenario that is not installed leaves the run plugin-less,
|
// bundled scenario that is not installed leaves the run plugin-less,
|
||||||
// which matches the legacy path.
|
// which matches the legacy path.
|
||||||
let resolvedSnapshot = null;
|
let resolvedSnapshot = null;
|
||||||
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
|
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
|
||||||
let registryView;
|
let registryView;
|
||||||
try {
|
try {
|
||||||
registryView = await loadPluginRegistryView();
|
registryView = await loadPluginRegistryView();
|
||||||
|
|
@ -13026,26 +13071,26 @@ export async function startServer({
|
||||||
return res.status(500).json({ error: String(err) });
|
return res.status(500).json({ error: String(err) });
|
||||||
}
|
}
|
||||||
const explicitPlugin =
|
const explicitPlugin =
|
||||||
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
|
requestBody.pluginId || requestBody.appliedPluginSnapshotId;
|
||||||
let runResolveBody = req.body;
|
let runResolveBody = requestBody;
|
||||||
if (!explicitPlugin) {
|
if (!explicitPlugin) {
|
||||||
const projectRow = getProject(db, req.body.projectId);
|
const projectRow = getProject(db, requestBody.projectId);
|
||||||
const hasPin =
|
const hasPin =
|
||||||
typeof projectRow?.appliedPluginSnapshotId === 'string'
|
typeof projectRow?.appliedPluginSnapshotId === 'string'
|
||||||
&& projectRow.appliedPluginSnapshotId.length > 0;
|
&& projectRow.appliedPluginSnapshotId.length > 0;
|
||||||
if (!hasPin) {
|
if (!hasPin) {
|
||||||
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
|
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
|
||||||
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
|
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
|
||||||
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
|
runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resolved = resolvePluginSnapshot({
|
const resolved = resolvePluginSnapshot({
|
||||||
db,
|
db,
|
||||||
body: runResolveBody,
|
body: runResolveBody,
|
||||||
projectId: req.body.projectId,
|
projectId: requestBody.projectId,
|
||||||
conversationId: typeof req.body.conversationId === 'string'
|
conversationId: typeof requestBody.conversationId === 'string'
|
||||||
? req.body.conversationId
|
? requestBody.conversationId
|
||||||
: null,
|
: null,
|
||||||
registry: registryView,
|
registry: registryView,
|
||||||
connectorProbe: buildConnectorProbe(connectorService),
|
connectorProbe: buildConnectorProbe(connectorService),
|
||||||
|
|
@ -13053,7 +13098,7 @@ export async function startServer({
|
||||||
if (resolved && !resolved.ok) {
|
if (resolved && !resolved.ok) {
|
||||||
if (!explicitPlugin) {
|
if (!explicitPlugin) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
|
`[plugins] default-scenario fallback skipped for run on project ${requestBody.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return res.status(resolved.status).json(resolved.body);
|
return res.status(resolved.status).json(resolved.body);
|
||||||
|
|
@ -13062,7 +13107,11 @@ export async function startServer({
|
||||||
resolvedSnapshot = resolved;
|
resolvedSnapshot = resolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
|
const meta = {
|
||||||
|
...requestBody,
|
||||||
|
mediaExecution: mediaExecution.policy,
|
||||||
|
toolBundle: toolBundle.bundle,
|
||||||
|
};
|
||||||
if (resolvedSnapshot?.ok) {
|
if (resolvedSnapshot?.ok) {
|
||||||
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
|
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
|
||||||
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
|
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
|
||||||
|
|
@ -13074,6 +13123,53 @@ export async function startServer({
|
||||||
if (renderedQuery.length > 0) meta.message = renderedQuery;
|
if (renderedQuery.length > 0) meta.message = renderedQuery;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let runProject = null;
|
||||||
|
if (typeof meta.projectId === 'string' && meta.projectId) {
|
||||||
|
try {
|
||||||
|
runProject = getProject(db, meta.projectId);
|
||||||
|
assertSandboxProjectRootAvailable(runProject?.metadata);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxImportedProjectError) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MCP / SDK callers may omit agentId. Resolve it before any run-create
|
||||||
|
// side effects so unsupported run-scoped tool bundles can fail cleanly.
|
||||||
|
if (typeof meta.agentId !== 'string' || !meta.agentId) {
|
||||||
|
try {
|
||||||
|
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
|
||||||
|
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
||||||
|
? appCfg.agentId
|
||||||
|
: null;
|
||||||
|
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||||
|
const cfgAgentAvailable = cfgAgent
|
||||||
|
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
||||||
|
: false;
|
||||||
|
if (cfgAgent && cfgAgentAvailable) {
|
||||||
|
meta.agentId = cfgAgent;
|
||||||
|
} else {
|
||||||
|
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
||||||
|
if (firstAvailable) meta.agentId = firstAvailable;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[runs] agent id fallback failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toolBundleSupport = validateRunToolBundleForAgent(
|
||||||
|
toolBundle.bundle,
|
||||||
|
typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null,
|
||||||
|
{
|
||||||
|
deliveryTarget: runToolBundleDeliveryTargetForProject(
|
||||||
|
meta.projectId,
|
||||||
|
runProject?.metadata,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!toolBundleSupport.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
|
||||||
|
}
|
||||||
// MCP / SDK callers POST /api/runs with just a projectId — no
|
// MCP / SDK callers POST /api/runs with just a projectId — no
|
||||||
// conversationId, no pre-created assistantMessageId — because they
|
// conversationId, no pre-created assistantMessageId — because they
|
||||||
// don't know about OD's chat-row lifecycle. The web flow
|
// don't know about OD's chat-row lifecycle. The web flow
|
||||||
|
|
@ -13133,30 +13229,6 @@ export async function startServer({
|
||||||
console.warn('[runs] mcp conversation fallback failed', err);
|
console.warn('[runs] mcp conversation fallback failed', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MCP / SDK callers may omit agentId. Resolve it from the saved
|
|
||||||
// app-config agent (the user's configured default) or the first
|
|
||||||
// available CLI so the run does not immediately fail with
|
|
||||||
// "unknown agent: undefined" inside startChatRun.
|
|
||||||
if (typeof meta.agentId !== 'string' || !meta.agentId) {
|
|
||||||
try {
|
|
||||||
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
|
|
||||||
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
|
||||||
? appCfg.agentId
|
|
||||||
: null;
|
|
||||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
|
||||||
const cfgAgentAvailable = cfgAgent
|
|
||||||
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
|
||||||
: false;
|
|
||||||
if (cfgAgent && cfgAgentAvailable) {
|
|
||||||
meta.agentId = cfgAgent;
|
|
||||||
} else {
|
|
||||||
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
|
||||||
if (firstAvailable) meta.agentId = firstAvailable;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[runs] agent id fallback failed', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const run = design.runs.create(meta);
|
const run = design.runs.create(meta);
|
||||||
try {
|
try {
|
||||||
pinAssistantMessageOnRunCreate(db, run);
|
pinAssistantMessageOnRunCreate(db, run);
|
||||||
|
|
@ -13571,11 +13643,45 @@ export async function startServer({
|
||||||
if (daemonShuttingDown) {
|
if (daemonShuttingDown) {
|
||||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||||
}
|
}
|
||||||
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
|
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
|
||||||
|
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
|
||||||
if (!mediaExecution.ok) {
|
if (!mediaExecution.ok) {
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||||
}
|
}
|
||||||
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
|
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
|
||||||
|
if (!toolBundle.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
|
||||||
|
}
|
||||||
|
let chatProject = null;
|
||||||
|
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
|
||||||
|
try {
|
||||||
|
chatProject = getProject(db, requestBody.projectId);
|
||||||
|
assertSandboxProjectRootAvailable(chatProject?.metadata);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxImportedProjectError) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toolBundleSupport = validateRunToolBundleForAgent(
|
||||||
|
toolBundle.bundle,
|
||||||
|
typeof requestBody.agentId === 'string' ? getAgentDef(requestBody.agentId) : null,
|
||||||
|
{
|
||||||
|
deliveryTarget: runToolBundleDeliveryTargetForProject(
|
||||||
|
requestBody.projectId,
|
||||||
|
chatProject?.metadata,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!toolBundleSupport.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
|
||||||
|
}
|
||||||
|
const meta = {
|
||||||
|
...requestBody,
|
||||||
|
mediaExecution: mediaExecution.policy,
|
||||||
|
toolBundle: toolBundle.bundle,
|
||||||
|
};
|
||||||
const run = design.runs.create(meta);
|
const run = design.runs.create(meta);
|
||||||
design.runs.stream(run, req, res);
|
design.runs.stream(run, req, res);
|
||||||
design.runs.start(run, () => startChatRun(meta, run));
|
design.runs.start(run, () => startChatRun(meta, run));
|
||||||
|
|
@ -13652,6 +13758,7 @@ export async function startServer({
|
||||||
if (routine.target.mode === 'reuse') {
|
if (routine.target.mode === 'reuse') {
|
||||||
const project = getProject(db, routine.target.projectId);
|
const project = getProject(db, routine.target.projectId);
|
||||||
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
|
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
|
||||||
|
assertSandboxProjectRootAvailable(project.metadata);
|
||||||
projectId = project.id;
|
projectId = project.id;
|
||||||
projectName = project.name;
|
projectName = project.name;
|
||||||
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;
|
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { homedir, tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {
|
import {
|
||||||
|
|
@ -623,6 +623,187 @@ describe('app-config telemetry prefs', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('app-config projectLocations', () => {
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dataDir = await mkdtemp(path.join(tmpdir(), 'od-projectLocations-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists valid projectLocations and reads them back', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'ext-one', name: 'One', path: '/tmp/od-loc-one' },
|
||||||
|
{ id: 'ext-two', name: 'Two', path: '/tmp/od-loc-two' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toEqual(locs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes ~/ paths via expandHomePrefix', async () => {
|
||||||
|
const home = homedir();
|
||||||
|
const locs = [{ id: 'home-loc', name: 'Home', path: '~/od-projects' }];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.path).toBe(path.join(home, 'od-projects'));
|
||||||
|
expect(path.isAbsolute(first.path)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops relative paths that cannot be resolved to absolute', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
|
||||||
|
{ id: 'bad-relative', name: 'Bad Rel', path: './relative/path' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.id).toBe('good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops entries without a string path', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
|
||||||
|
{ id: 'no-path', name: 'No Path' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs as any });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.id).toBe('good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates paths (case-sensitive on unix)', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'first', name: 'First', path: '/tmp/od-same' },
|
||||||
|
{ id: 'second', name: 'Second', path: '/tmp/od-same' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
// Single canonical entry, second deduplicated
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.path).toBe(path.normalize('/tmp/od-same'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by resolved path after normalization', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'first', name: 'First', path: '/tmp/od-dup/../od-dup' },
|
||||||
|
{ id: 'second', name: 'Second', path: '/tmp/od-dup' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.path).toBe(path.normalize('/tmp/od-dup'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects reserved id "default" and falls back to auto-generated id', async () => {
|
||||||
|
const locs = [{ id: 'default', name: 'Hijack', path: '/tmp/od-hijack' }];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
// The stored id must NOT be 'default'
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.id).not.toBe('default');
|
||||||
|
// The auto-generated id follows the hash-backed base64url pattern
|
||||||
|
expect(first.id).toMatch(/^loc_[A-Za-z0-9_-]{1,16}$/);
|
||||||
|
expect(first.path).toBe(path.normalize('/tmp/od-hijack'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates distinct ids for sibling paths with long shared prefixes', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ path: '/tmp/open-design-project-locations/shared-prefix-one' },
|
||||||
|
{ path: '/tmp/open-design-project-locations/shared-prefix-two' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(2);
|
||||||
|
const ids = cfg.projectLocations!.map((location) => location.id);
|
||||||
|
expect(new Set(ids).size).toBe(2);
|
||||||
|
expect(ids.every((id) => /^loc_[A-Za-z0-9_-]{1,16}$/.test(id))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists a defaultProjectLocationId preference', async () => {
|
||||||
|
await writeAppConfig(dataDir, {
|
||||||
|
projectLocations: [{ id: 'external-default', name: 'External', path: '/tmp/od-default-location' }],
|
||||||
|
defaultProjectLocationId: 'external-default',
|
||||||
|
});
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.defaultProjectLocationId).toBe('external-default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes invalid defaultProjectLocationId values', async () => {
|
||||||
|
await writeAppConfig(dataDir, { defaultProjectLocationId: '../bad' });
|
||||||
|
let cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.defaultProjectLocationId).toBe('default');
|
||||||
|
|
||||||
|
await writeAppConfig(dataDir, { defaultProjectLocationId: null });
|
||||||
|
cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.defaultProjectLocationId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops invalid scalar projectLocations (not an array)', async () => {
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: 'not-array' } as any);
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears projectLocations when empty array is sent', async () => {
|
||||||
|
await writeAppConfig(dataDir, {
|
||||||
|
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
|
||||||
|
onboardingCompleted: true,
|
||||||
|
});
|
||||||
|
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: [] });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toEqual([]);
|
||||||
|
expect(cfg.onboardingCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears projectLocations when null is sent', async () => {
|
||||||
|
await writeAppConfig(dataDir, {
|
||||||
|
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
|
||||||
|
onboardingCompleted: true,
|
||||||
|
});
|
||||||
|
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: null as any });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toBeUndefined();
|
||||||
|
expect(cfg.onboardingCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates projectLocations on read (filters corrupted stored data)', async () => {
|
||||||
|
// Write raw JSON with invalid entries
|
||||||
|
await writeFile(
|
||||||
|
path.join(dataDir, 'app-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
projectLocations: [
|
||||||
|
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
|
||||||
|
{ id: 'bad-relative', name: 'Bad', path: 'relative' },
|
||||||
|
{ id: 'no-path', name: 'No Path' },
|
||||||
|
'not-an-object',
|
||||||
|
null,
|
||||||
|
{ id: 'good2', name: 'Dup Path', path: '/tmp/od-good' },
|
||||||
|
{ id: 'default', name: 'Reserved', path: '/tmp/od-reserved' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(2);
|
||||||
|
const ids = cfg.projectLocations!.map((l) => l.id);
|
||||||
|
expect(ids).not.toContain('default');
|
||||||
|
expect(ids).not.toContain('bad-relative');
|
||||||
|
expect(ids).not.toContain('no-path');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('app-config origin guard', () => {
|
describe('app-config origin guard', () => {
|
||||||
let server: http.Server;
|
let server: http.Server;
|
||||||
let port: number;
|
let port: number;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { closeDatabase, insertProject, openDatabase } from '../src/db.js';
|
import { closeDatabase, insertProject, openDatabase } from '../src/db.js';
|
||||||
import { reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js';
|
import { isRunTouchedProjectFile, reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js';
|
||||||
|
|
||||||
const PROJECT_ID = 'reconcile-test';
|
const PROJECT_ID = 'reconcile-test';
|
||||||
let tempDir = null;
|
let tempDir = null;
|
||||||
|
|
@ -146,6 +146,9 @@ describe('run-end artifact manifest reconciliation (#2893)', () => {
|
||||||
|
|
||||||
// File written during the run
|
// File written during the run
|
||||||
await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '<p>new</p>');
|
await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '<p>new</p>');
|
||||||
|
const newPath = path.join(projectsRoot, PROJECT_ID, 'new-output.html');
|
||||||
|
const coarseFsTime = new Date(runStartTimeMs - 500);
|
||||||
|
fs.utimesSync(newPath, coarseFsTime, coarseFsTime);
|
||||||
|
|
||||||
// Simulate the close-handler reconciliation with mtime filter
|
// Simulate the close-handler reconciliation with mtime filter
|
||||||
const dir = path.join(projectsRoot, PROJECT_ID);
|
const dir = path.join(projectsRoot, PROJECT_ID);
|
||||||
|
|
@ -154,7 +157,7 @@ describe('run-end artifact manifest reconciliation (#2893)', () => {
|
||||||
const ext = path.extname(name).toLowerCase();
|
const ext = path.extname(name).toLowerCase();
|
||||||
if (ext !== '.html' && ext !== '.htm') continue;
|
if (ext !== '.html' && ext !== '.htm') continue;
|
||||||
const st = fs.statSync(path.join(dir, name));
|
const st = fs.statSync(path.join(dir, name));
|
||||||
if (st.mtimeMs < runStartTimeMs) continue;
|
if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue;
|
||||||
await reconcileHtmlArtifactManifest(projectsRoot, PROJECT_ID, name);
|
await reconcileHtmlArtifactManifest(projectsRoot, PROJECT_ID, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,42 @@ async function withFakeAgent<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withOnlyFakeAgent<T>(
|
||||||
|
binName: string,
|
||||||
|
script: string,
|
||||||
|
run: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
|
||||||
|
const oldPath = process.env.PATH;
|
||||||
|
const oldAgentHome = process.env.OD_AGENT_HOME;
|
||||||
|
const oldClaudeBin = process.env.CLAUDE_BIN;
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const runner = path.join(dir, `${binName}-test-runner.cjs`);
|
||||||
|
await fsp.writeFile(runner, script);
|
||||||
|
await fsp.writeFile(
|
||||||
|
path.join(dir, `${binName}.cmd`),
|
||||||
|
`@echo off\r\nnode "${runner}" %*\r\n`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const bin = path.join(dir, binName);
|
||||||
|
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
||||||
|
await fsp.chmod(bin, 0o755);
|
||||||
|
}
|
||||||
|
process.env.PATH = dir;
|
||||||
|
process.env.OD_AGENT_HOME = dir;
|
||||||
|
delete process.env.CLAUDE_BIN;
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = oldPath;
|
||||||
|
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
|
||||||
|
else process.env.OD_AGENT_HOME = oldAgentHome;
|
||||||
|
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
|
||||||
|
else process.env.CLAUDE_BIN = oldClaudeBin;
|
||||||
|
await fsp.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
|
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||||
return withFakeAgent('codex', script, run);
|
return withFakeAgent('codex', script, run);
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
|
||||||
return withFakeAgent('claude', script, run);
|
return withFakeAgent('claude', script, run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
return withOnlyFakeAgent('openclaude', script, run);
|
||||||
|
}
|
||||||
|
|
||||||
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
|
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||||
return withFakeAgent('opencode', script, run);
|
return withFakeAgent('opencode', script, run);
|
||||||
}
|
}
|
||||||
|
|
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
|
||||||
|
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
|
||||||
|
const previousKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
try {
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
|
||||||
|
await withOnlyFakeOpenClaude(
|
||||||
|
`
|
||||||
|
const fs = require('node:fs');
|
||||||
|
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
|
||||||
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
|
||||||
|
}));
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
try {
|
||||||
|
JSON.parse(input.trim());
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
message: {
|
||||||
|
id: 'msg_1',
|
||||||
|
content: [{ type: 'text', text: 'ok' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
async () => {
|
||||||
|
const result = await testAgentConnection({ agentId: 'claude' });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
kind: 'success',
|
||||||
|
agentName: 'Claude Code',
|
||||||
|
});
|
||||||
|
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
|
||||||
|
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
|
||||||
|
);
|
||||||
|
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
else process.env.ANTHROPIC_API_KEY = previousKey;
|
||||||
|
await fsp.rm(envFile, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
|
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
|
||||||
await withFakeClaude(
|
await withFakeClaude(
|
||||||
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,
|
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,
|
||||||
|
|
|
||||||
364
apps/daemon/tests/export-manifest-route.test.ts
Normal file
364
apps/daemon/tests/export-manifest-route.test.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
import type http from 'node:http';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { writeFile as writeFsFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { startServer } from '../src/server.js';
|
||||||
|
|
||||||
|
describe('project export manifest route', () => {
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
const projectsToClean: string[] = [];
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||||
|
url: string;
|
||||||
|
server: http.Server;
|
||||||
|
};
|
||||||
|
baseUrl = started.url;
|
||||||
|
server = started.server;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of projectsToClean.splice(0)) {
|
||||||
|
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFolder(): string {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), 'od-export-manifest-'));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = process.env.OD_SANDBOX_MODE;
|
||||||
|
process.env.OD_SANDBOX_MODE = '1';
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||||
|
else process.env.OD_SANDBOX_MODE = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(
|
||||||
|
metadata: Record<string, unknown> = { kind: 'prototype', entryFile: 'index.html' },
|
||||||
|
): Promise<string> {
|
||||||
|
const id = `export-manifest-${randomUUID()}`;
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
name: 'Export manifest project',
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
projectsToClean.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFile(projectId: string, body: Record<string, unknown>): Promise<void> {
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFile(projectId: string, from: string, to: string): Promise<void> {
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ from, to }),
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lists exportable project files and artifact sidecar metadata without exposing sidecars', async () => {
|
||||||
|
const projectId = await createProject();
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'styles.css',
|
||||||
|
content: 'body { color: black; }',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'assets/logo.svg',
|
||||||
|
content: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'index.html',
|
||||||
|
content: '<!doctype html><link rel="stylesheet" href="styles.css">',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Reviewed prototype',
|
||||||
|
entry: 'index.html',
|
||||||
|
renderer: 'html',
|
||||||
|
status: 'complete',
|
||||||
|
exports: ['html', 'zip'],
|
||||||
|
primary: true,
|
||||||
|
supportingFiles: ['styles.css', 'assets/logo.svg', 'missing.png'],
|
||||||
|
updatedAt: '2026-05-28T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const body = await response.json() as {
|
||||||
|
schema: string;
|
||||||
|
projectId: string;
|
||||||
|
entryFile: string;
|
||||||
|
files: Array<{ name: string; role: string; reasons: string[]; artifactManifest?: unknown }>;
|
||||||
|
artifacts: Array<{ file: string; title: string; supportingFiles: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
schema: 'open-design.project-export-manifest.v1',
|
||||||
|
projectId,
|
||||||
|
entryFile: 'index.html',
|
||||||
|
});
|
||||||
|
expect(body.files.map((file) => file.name)).toEqual([
|
||||||
|
'assets/logo.svg',
|
||||||
|
'index.html',
|
||||||
|
'styles.css',
|
||||||
|
]);
|
||||||
|
expect(body.files.find((file) => file.name === 'index.html')).toMatchObject({
|
||||||
|
role: 'entry',
|
||||||
|
reasons: expect.arrayContaining(['artifact-manifest', 'project-entry-file']),
|
||||||
|
});
|
||||||
|
expect(body.files.find((file) => file.name === 'styles.css')).toMatchObject({
|
||||||
|
role: 'supporting',
|
||||||
|
reasons: ['artifact-supporting-file'],
|
||||||
|
});
|
||||||
|
expect(body.artifacts).toMatchObject([
|
||||||
|
{
|
||||||
|
file: 'index.html',
|
||||||
|
title: 'Reviewed prototype',
|
||||||
|
supportingFiles: ['assets/logo.svg', 'styles.css'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(body.files.some((file) => file.name.endsWith('.artifact.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses artifact primary strings as project-relative entry refs', async () => {
|
||||||
|
const projectId = await createProject({ kind: 'prototype' });
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'reviewed.html',
|
||||||
|
content: '<!doctype html><main>reviewed</main>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'preview/wrapper.html',
|
||||||
|
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Review wrapper',
|
||||||
|
renderer: 'html',
|
||||||
|
status: 'complete',
|
||||||
|
exports: ['html'],
|
||||||
|
primary: 'reviewed.html',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const body = await response.json() as {
|
||||||
|
entryFile: string;
|
||||||
|
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.entryFile).toBe('reviewed.html');
|
||||||
|
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
||||||
|
role: 'entry',
|
||||||
|
reasons: expect.arrayContaining(['artifact-primary', 'project-entry-file']),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses artifact entry strings as project-relative entry refs without primary hints', async () => {
|
||||||
|
const projectId = await createProject({ kind: 'prototype' });
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'index.html',
|
||||||
|
content: '<!doctype html><main>fallback</main>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'reviewed.html',
|
||||||
|
content: '<!doctype html><main>reviewed</main>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'preview/wrapper.html',
|
||||||
|
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Review wrapper',
|
||||||
|
entry: 'reviewed.html',
|
||||||
|
renderer: 'html',
|
||||||
|
status: 'complete',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const body = await response.json() as {
|
||||||
|
entryFile: string;
|
||||||
|
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.entryFile).toBe('reviewed.html');
|
||||||
|
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
||||||
|
role: 'entry',
|
||||||
|
reasons: expect.arrayContaining(['artifact-entry', 'project-entry-file']),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps artifact entry refs current when a referenced file is renamed', async () => {
|
||||||
|
const projectId = await createProject({ kind: 'prototype' });
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'index.html',
|
||||||
|
content: '<!doctype html><main>fallback</main>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'reviewed.html',
|
||||||
|
content: '<!doctype html><main>reviewed</main>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'preview/wrapper.html',
|
||||||
|
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Review wrapper',
|
||||||
|
entry: 'reviewed.html',
|
||||||
|
renderer: 'html',
|
||||||
|
status: 'complete',
|
||||||
|
exports: ['html'],
|
||||||
|
primary: 'reviewed.html',
|
||||||
|
supportingFiles: ['reviewed.html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await renameFile(projectId, 'reviewed.html', 'reviewed-renamed.html');
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const body = await response.json() as {
|
||||||
|
entryFile: string;
|
||||||
|
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.entryFile).toBe('reviewed-renamed.html');
|
||||||
|
expect(body.files.find((file) => file.name === 'reviewed-renamed.html')).toMatchObject({
|
||||||
|
role: 'entry',
|
||||||
|
reasons: expect.arrayContaining(['artifact-entry', 'artifact-primary', 'project-entry-file']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
|
||||||
|
expect(filesResponse.ok).toBe(true);
|
||||||
|
const filesBody = await filesResponse.json() as {
|
||||||
|
files: Array<{
|
||||||
|
name: string;
|
||||||
|
artifactManifest?: {
|
||||||
|
entry?: string;
|
||||||
|
primary?: string | boolean;
|
||||||
|
supportingFiles?: string[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
expect(filesBody.files.find((file) => file.name === 'preview/wrapper.html')?.artifactManifest)
|
||||||
|
.toMatchObject({
|
||||||
|
entry: 'reviewed-renamed.html',
|
||||||
|
primary: 'reviewed-renamed.html',
|
||||||
|
supportingFiles: ['reviewed-renamed.html'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps artifact entry refs current when a referenced file moves out of the wrapper directory', async () => {
|
||||||
|
const projectId = await createProject({ kind: 'prototype' });
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'index.html',
|
||||||
|
content: '<!doctype html><main>fallback</main>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'preview/reviewed.html',
|
||||||
|
content: '<!doctype html><main>reviewed</main>',
|
||||||
|
});
|
||||||
|
await writeFile(projectId, {
|
||||||
|
name: 'preview/wrapper.html',
|
||||||
|
content: '<!doctype html><iframe src="reviewed.html"></iframe>',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Review wrapper',
|
||||||
|
entry: 'reviewed.html',
|
||||||
|
renderer: 'html',
|
||||||
|
status: 'complete',
|
||||||
|
exports: ['html'],
|
||||||
|
primary: 'reviewed.html',
|
||||||
|
supportingFiles: ['reviewed.html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await renameFile(projectId, 'preview/reviewed.html', 'reviewed.html');
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const body = await response.json() as {
|
||||||
|
entryFile: string;
|
||||||
|
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||||
|
artifacts: Array<{ file: string; supportingFiles: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.entryFile).toBe('reviewed.html');
|
||||||
|
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
||||||
|
role: 'entry',
|
||||||
|
reasons: expect.arrayContaining([
|
||||||
|
'artifact-entry',
|
||||||
|
'artifact-primary',
|
||||||
|
'artifact-supporting-file',
|
||||||
|
'project-entry-file',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
expect(body.artifacts.find((artifact) => artifact.file === 'preview/wrapper.html'))
|
||||||
|
.toMatchObject({
|
||||||
|
supportingFiles: ['reviewed.html'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid project ids before listing files', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects imported-folder projects in sandbox mode instead of returning an empty manifest', async () => {
|
||||||
|
const folder = makeFolder();
|
||||||
|
await writeFsFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
||||||
|
const importResponse = await fetch(`${baseUrl}/api/import/folder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ baseDir: folder }),
|
||||||
|
});
|
||||||
|
expect(importResponse.status).toBe(200);
|
||||||
|
const importBody = (await importResponse.json()) as { project: { id: string } };
|
||||||
|
projectsToClean.push(importBody.project.id);
|
||||||
|
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${importBody.project.id}/export/manifest`);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = (await response.json()) as { error?: { message?: string } };
|
||||||
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type http from 'node:http';
|
import type http from 'node:http';
|
||||||
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
||||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
@ -56,26 +56,6 @@ describe('POST /api/import/folder', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForRunStatus(
|
|
||||||
runId: string,
|
|
||||||
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
|
||||||
let lastStatus = 'unknown';
|
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
||||||
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
|
|
||||||
const statusBody = (await statusResponse.json()) as {
|
|
||||||
status: string;
|
|
||||||
error?: string | null;
|
|
||||||
errorCode?: string | null;
|
|
||||||
};
|
|
||||||
lastStatus = statusBody.status;
|
|
||||||
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
|
|
||||||
return statusBody;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
||||||
}
|
|
||||||
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('creates a project rooted at the submitted folder', async () => {
|
it('creates a project rooted at the submitted folder', async () => {
|
||||||
const folder = makeFolder();
|
const folder = makeFolder();
|
||||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
@ -105,7 +85,7 @@ describe('POST /api/import/folder', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
|
it('rejects sandbox runs for imported folders before creating a run', async () => {
|
||||||
const folder = makeFolder();
|
const folder = makeFolder();
|
||||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
||||||
|
|
@ -123,15 +103,78 @@ describe('POST /api/import/folder', () => {
|
||||||
message: 'Inspect the imported project.',
|
message: 'Inspect the imported project.',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(runResp.status).toBe(202);
|
expect(runResp.status).toBe(400);
|
||||||
const { runId } = (await runResp.json()) as { runId: string };
|
const body = (await runResp.json()) as { error?: { message?: string } };
|
||||||
const status = await waitForRunStatus(runId);
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
expect(status.status).toBe('failed');
|
|
||||||
expect(status.errorCode).toBe('BAD_REQUEST');
|
|
||||||
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects sandbox chat runs for imported folders before creating a run', async () => {
|
||||||
|
const folder = makeFolder();
|
||||||
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
||||||
|
const importResp = await importFolder({ baseDir: folder });
|
||||||
|
expect(importResp.status).toBe(200);
|
||||||
|
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||||
|
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const chatResp = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: project.id,
|
||||||
|
message: 'Inspect the imported project.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatResp.status).toBe(400);
|
||||||
|
const body = (await chatResp.json()) as { error?: { message?: string } };
|
||||||
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
|
|
||||||
|
const runsResp = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(project.id)}`);
|
||||||
|
expect(runsResp.status).toBe(200);
|
||||||
|
const runsBody = (await runsResp.json()) as { runs: unknown[] };
|
||||||
|
expect(runsBody.runs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens imported-folder projects through host editor routes in sandbox mode', async () => {
|
||||||
|
const folder = makeFolder();
|
||||||
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
const binDir = makeFolder();
|
||||||
|
const cursorBin = path.join(
|
||||||
|
binDir,
|
||||||
|
process.platform === 'win32' ? 'cursor.cmd' : 'cursor',
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
cursorBin,
|
||||||
|
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
|
||||||
|
);
|
||||||
|
await chmod(cursorBin, 0o755);
|
||||||
|
|
||||||
|
const importResp = await importFolder({ baseDir: folder });
|
||||||
|
expect(importResp.status).toBe(200);
|
||||||
|
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||||
|
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`;
|
||||||
|
try {
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/open-in`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ editorId: 'cursor' }),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
const body = (await resp.json()) as { path?: string };
|
||||||
|
expect(body.path).toBe(await realpath(folder));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousPath == null) delete process.env.PATH;
|
||||||
|
else process.env.PATH = previousPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('still opens an imported-folder project record in sandbox mode', async () => {
|
it('still opens an imported-folder project record in sandbox mode', async () => {
|
||||||
const folder = makeFolder();
|
const folder = makeFolder();
|
||||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,14 @@ process.exit(0);
|
||||||
async function waitForRunStatus(
|
async function waitForRunStatus(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
|
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
|
||||||
const body = (await r.json()) as { status: string };
|
const body = (await r.json()) as {
|
||||||
|
status: string;
|
||||||
|
error?: string | null;
|
||||||
|
errorCode?: string | null;
|
||||||
|
};
|
||||||
if (body.status !== 'queued' && body.status !== 'running') return body;
|
if (body.status !== 'queued' && body.status !== 'running') return body;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +86,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
let server: http.Server;
|
let server: http.Server;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
const projectsToClean: string[] = [];
|
const projectsToClean: string[] = [];
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||||
|
|
@ -106,9 +111,12 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ servers: [] }),
|
body: JSON.stringify({ servers: [] }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createProject(): Promise<{ id: string; dir: string }> {
|
async function createProject(): Promise<{ id: string; dir: string; conversationId: string }> {
|
||||||
const id = `mcp-spawn-${randomUUID()}`;
|
const id = `mcp-spawn-${randomUUID()}`;
|
||||||
const r = await fetch(`${baseUrl}/api/projects`, {
|
const r = await fetch(`${baseUrl}/api/projects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
body: JSON.stringify({ id, name: id }),
|
body: JSON.stringify({ id, name: id }),
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
|
const body = (await r.json()) as { conversationId: string };
|
||||||
projectsToClean.push(id);
|
projectsToClean.push(id);
|
||||||
// The daemon owns its data dir; we discover the on-disk project path by
|
// The daemon owns its data dir; we discover the on-disk project path by
|
||||||
// having the daemon return the upload root, then composing path manually.
|
// having the daemon return the upload root, then composing path manually.
|
||||||
|
|
@ -123,7 +132,46 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
const projectsBase = process.env.OD_DATA_DIR
|
const projectsBase = process.env.OD_DATA_DIR
|
||||||
? join(process.env.OD_DATA_DIR, 'projects')
|
? join(process.env.OD_DATA_DIR, 'projects')
|
||||||
: join(process.cwd(), '.od', 'projects');
|
: join(process.cwd(), '.od', 'projects');
|
||||||
return { id, dir: join(projectsBase, id) };
|
return { id, dir: join(projectsBase, id), conversationId: body.conversationId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFolderProject(): Promise<{
|
||||||
|
id: string;
|
||||||
|
dir: string;
|
||||||
|
externalDir: string;
|
||||||
|
conversationId: string;
|
||||||
|
}> {
|
||||||
|
const externalDir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-import-'));
|
||||||
|
tempDirs.push(externalDir);
|
||||||
|
await fsp.writeFile(join(externalDir, 'index.html'), '<!doctype html>');
|
||||||
|
const r = await fetch(`${baseUrl}/api/import/folder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ baseDir: externalDir }),
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const body = (await r.json()) as { project: { id: string }; conversationId: string };
|
||||||
|
projectsToClean.push(body.project.id);
|
||||||
|
const projectsBase = process.env.OD_DATA_DIR
|
||||||
|
? join(process.env.OD_DATA_DIR, 'projects')
|
||||||
|
: join(process.cwd(), '.od', 'projects');
|
||||||
|
return {
|
||||||
|
id: body.project.id,
|
||||||
|
dir: join(projectsBase, body.project.id),
|
||||||
|
externalDir,
|
||||||
|
conversationId: body.conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = process.env.OD_SANDBOX_MODE;
|
||||||
|
process.env.OD_SANDBOX_MODE = '1';
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||||
|
else process.env.OD_SANDBOX_MODE = previous;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
|
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
|
||||||
|
|
@ -197,6 +245,414 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
});
|
});
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
it('fails sandbox runs for imported-folder projects before writing MCP config', async () => {
|
||||||
|
await withFakeClaude(async () => {
|
||||||
|
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
id: 'sandbox-run',
|
||||||
|
transport: 'sse',
|
||||||
|
enabled: true,
|
||||||
|
url: 'https://mcp.example.test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(putRes.ok).toBe(true);
|
||||||
|
|
||||||
|
const { id, dir, externalDir, conversationId } = await importFolderProject();
|
||||||
|
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'hello sandbox mcp',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatRes.status).toBe(400);
|
||||||
|
const body = (await chatRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
const managedTarget = join(dir, '.mcp.json');
|
||||||
|
expect(existsSync(managedTarget)).toBe(false);
|
||||||
|
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
|
||||||
|
const messagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(messagesRes.ok).toBe(true);
|
||||||
|
const messagesBody = (await messagesRes.json()) as {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
};
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'hello sandbox mcp')).toBe(false);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects sandbox routine reuse of imported-folder projects before creating run state', async () => {
|
||||||
|
const { id } = await importFolderProject();
|
||||||
|
const conversationsBeforeRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
|
||||||
|
expect(conversationsBeforeRes.ok).toBe(true);
|
||||||
|
const conversationsBeforeBody = (await conversationsBeforeRes.json()) as {
|
||||||
|
conversations: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
const conversationIdsBefore = conversationsBeforeBody.conversations.map((conversation) => conversation.id);
|
||||||
|
|
||||||
|
let routineId: string | null = null;
|
||||||
|
try {
|
||||||
|
const createRoutineRes = await fetch(`${baseUrl}/api/routines`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Sandbox imported folder routine',
|
||||||
|
prompt: 'try to run inside an imported folder',
|
||||||
|
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||||
|
target: { mode: 'reuse', projectId: id },
|
||||||
|
agentId: 'claude',
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createRoutineRes.status).toBe(201);
|
||||||
|
const createRoutineBody = (await createRoutineRes.json()) as {
|
||||||
|
routine: { id: string };
|
||||||
|
};
|
||||||
|
routineId = createRoutineBody.routine.id;
|
||||||
|
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const runRoutineRes = await fetch(`${baseUrl}/api/routines/${routineId}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
expect(runRoutineRes.status).toBe(500);
|
||||||
|
const runRoutineBody = (await runRoutineRes.json()) as { error?: string };
|
||||||
|
expect(runRoutineBody.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
const routineRunsRes = await fetch(`${baseUrl}/api/routines/${routineId}/runs?limit=10`);
|
||||||
|
expect(routineRunsRes.ok).toBe(true);
|
||||||
|
const routineRunsBody = (await routineRunsRes.json()) as { runs: unknown[] };
|
||||||
|
expect(routineRunsBody.runs).toHaveLength(0);
|
||||||
|
|
||||||
|
const runsRes = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(id)}`);
|
||||||
|
expect(runsRes.ok).toBe(true);
|
||||||
|
const runsBody = (await runsRes.json()) as { runs: unknown[] };
|
||||||
|
expect(runsBody.runs).toHaveLength(0);
|
||||||
|
|
||||||
|
const conversationsAfterRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
|
||||||
|
expect(conversationsAfterRes.ok).toBe(true);
|
||||||
|
const conversationsAfterBody = (await conversationsAfterRes.json()) as {
|
||||||
|
conversations: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
expect(conversationsAfterBody.conversations.map((conversation) => conversation.id)).toEqual(
|
||||||
|
conversationIdsBefore,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (routineId) {
|
||||||
|
await fetch(`${baseUrl}/api/routines/${routineId}`, { method: 'DELETE' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('binds conversation-less runs to the seeded project conversation', async () => {
|
||||||
|
await withFakeClaude(async () => {
|
||||||
|
const { id, conversationId } = await createProject();
|
||||||
|
const recentConvRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: 'Recently active' }),
|
||||||
|
});
|
||||||
|
expect(recentConvRes.ok).toBe(true);
|
||||||
|
const recentConvBody = (await recentConvRes.json()) as {
|
||||||
|
conversation: { id: string };
|
||||||
|
};
|
||||||
|
const recentConversationId = recentConvBody.conversation.id;
|
||||||
|
await fetch(`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'Recently active',
|
||||||
|
updatedAt: Date.now() + 60_000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'headless fallback prompt',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatRes.status).toBe(202);
|
||||||
|
const { runId, conversationId: resolvedConversationId } = (await chatRes.json()) as {
|
||||||
|
runId: string;
|
||||||
|
conversationId: string;
|
||||||
|
};
|
||||||
|
expect(resolvedConversationId).toBe(conversationId);
|
||||||
|
const status = await waitForRunStatus(baseUrl, runId);
|
||||||
|
expect(status.status).toBe('succeeded');
|
||||||
|
|
||||||
|
const defaultMessagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(defaultMessagesRes.ok).toBe(true);
|
||||||
|
const defaultMessages = (await defaultMessagesRes.json()) as {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
};
|
||||||
|
expect(defaultMessages.messages).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'user',
|
||||||
|
content: 'headless fallback prompt',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentMessagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(recentMessagesRes.ok).toBe(true);
|
||||||
|
const recentMessages = (await recentMessagesRes.json()) as {
|
||||||
|
messages: Array<{ content: string }>;
|
||||||
|
};
|
||||||
|
expect(recentMessages.messages.some((msg) => msg.content === 'headless fallback prompt')).toBe(false);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
|
||||||
|
await withFakeClaude(async () => {
|
||||||
|
const { id, dir } = await createProject();
|
||||||
|
|
||||||
|
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'hello run-scoped mcp',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['run-tool.js'],
|
||||||
|
env: { RUN_ONLY: '1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'run-remote',
|
||||||
|
transport: 'http',
|
||||||
|
enabled: true,
|
||||||
|
authMode: 'none',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
headers: { 'X-Run': 'ok' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatRes.status).toBe(202);
|
||||||
|
const { runId } = (await chatRes.json()) as { runId: string };
|
||||||
|
const status = await waitForRunStatus(baseUrl, runId) as {
|
||||||
|
status: string;
|
||||||
|
toolBundle?: { mcpServers?: Array<{ id: string }> };
|
||||||
|
};
|
||||||
|
expect(status.status).toBe('succeeded');
|
||||||
|
expect(status.toolBundle?.mcpServers?.map((server) => server.id)).toEqual([
|
||||||
|
'run-local',
|
||||||
|
'run-remote',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const target = join(dir, '.mcp.json');
|
||||||
|
expect(existsSync(target)).toBe(true);
|
||||||
|
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
|
||||||
|
expect(written.mcpServers.run_local).toBeUndefined();
|
||||||
|
expect(written.mcpServers['run-local']).toMatchObject({
|
||||||
|
command: 'node',
|
||||||
|
args: ['run-tool.js'],
|
||||||
|
env: { RUN_ONLY: '1' },
|
||||||
|
});
|
||||||
|
expect(written.mcpServers['run-remote']).toMatchObject({
|
||||||
|
type: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
headers: { 'X-Run': 'ok' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistedRes = await fetch(`${baseUrl}/api/mcp/servers`);
|
||||||
|
expect(persistedRes.ok).toBe(true);
|
||||||
|
const persisted = (await persistedRes.json()) as { servers: unknown[] };
|
||||||
|
expect(persisted.servers).toEqual([]);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects Claude run-scoped MCP bundles for imported-folder projects', async () => {
|
||||||
|
const { id, dir, externalDir, conversationId } = await importFolderProject();
|
||||||
|
|
||||||
|
const runsRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'imported run-scoped tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(runsRes.status).toBe(400);
|
||||||
|
const runsBody = (await runsRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(runsBody.error?.message).toContain('toolBundle requires a daemon-managed project');
|
||||||
|
|
||||||
|
const chatRes = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'imported chat-scoped tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local-chat',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatRes.status).toBe(400);
|
||||||
|
const chatBody = (await chatRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(chatBody.error?.message).toContain('toolBundle requires a daemon-managed project');
|
||||||
|
|
||||||
|
expect(existsSync(join(dir, '.mcp.json'))).toBe(false);
|
||||||
|
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
|
||||||
|
const messagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(messagesRes.ok).toBe(true);
|
||||||
|
const messagesBody = (await messagesRes.json()) as {
|
||||||
|
messages: Array<{ content: string }>;
|
||||||
|
};
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'imported run-scoped tools')).toBe(false);
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'imported chat-scoped tools')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed run-scoped MCP bundles before creating runs', async () => {
|
||||||
|
const { id } = await createProject();
|
||||||
|
|
||||||
|
const invalidRunsRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'missing-command',
|
||||||
|
transport: 'stdio',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(invalidRunsRes.status).toBe(400);
|
||||||
|
const runsBody = (await invalidRunsRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(runsBody.error?.message).toContain('toolBundle.mcpServers[0] is invalid');
|
||||||
|
|
||||||
|
const invalidChatRes = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad tools',
|
||||||
|
toolBundle: 'bad',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(invalidChatRes.status).toBe(400);
|
||||||
|
const chatBody = (await invalidChatRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(chatBody.error?.message).toContain('toolBundle must be an object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects run-scoped MCP bundles the selected runtime cannot receive', async () => {
|
||||||
|
const { id, conversationId } = await createProject();
|
||||||
|
|
||||||
|
const unsupportedRuntimeRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'codex',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(unsupportedRuntimeRes.status).toBe(400);
|
||||||
|
const unsupportedRuntimeBody = (await unsupportedRuntimeRes.json()) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
expect(unsupportedRuntimeBody.error?.message).toContain(
|
||||||
|
'Codex CLI (codex) does not support run-scoped MCP tool bundles',
|
||||||
|
);
|
||||||
|
const messagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(messagesRes.ok).toBe(true);
|
||||||
|
const messagesBody = (await messagesRes.json()) as {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
};
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'bad tools')).toBe(false);
|
||||||
|
|
||||||
|
const unsupportedTransportRes = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'hermes',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad remote tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-remote',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(unsupportedTransportRes.status).toBe(400);
|
||||||
|
const unsupportedTransportBody = (await unsupportedTransportRes.json()) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
expect(unsupportedTransportBody.error?.message).toContain(
|
||||||
|
'Hermes (hermes) only supports stdio run-scoped MCP servers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
|
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
|
||||||
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
|
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
|
||||||
// session/new params instead of `.mcp.json`. The `.mcp.json` write path
|
// session/new params instead of `.mcp.json`. The `.mcp.json` write path
|
||||||
|
|
|
||||||
122
apps/daemon/tests/project-preview-containment.test.ts
Normal file
122
apps/daemon/tests/project-preview-containment.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import type http from 'node:http';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { startServer } from '../src/server.js';
|
||||||
|
|
||||||
|
describe('project preview containment routes', () => {
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
const projectsToClean: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||||
|
url: string;
|
||||||
|
server: http.Server;
|
||||||
|
};
|
||||||
|
baseUrl = started.url;
|
||||||
|
server = started.server;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of projectsToClean.splice(0)) {
|
||||||
|
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createProject(metadata: Record<string, unknown> = {}): Promise<string> {
|
||||||
|
const id = `preview-containment-${randomUUID()}`;
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
name: 'Preview containment project',
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
projectsToClean.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProjectFile(projectId: string, name: string, content: string): Promise<void> {
|
||||||
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, content }),
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns a scoped preview URL with sandbox guidance and serves it with an opaque-origin CSP', async () => {
|
||||||
|
const projectId = await createProject({ entryFile: 'pages/index.html' });
|
||||||
|
await writeProjectFile(
|
||||||
|
projectId,
|
||||||
|
'pages/index.html',
|
||||||
|
'<!doctype html><title>Preview</title><link rel="stylesheet" href="../styles/app.css">',
|
||||||
|
);
|
||||||
|
await writeProjectFile(projectId, 'styles/app.css', 'body { color: black; }');
|
||||||
|
|
||||||
|
const urlResponse = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('pages/index.html')}`,
|
||||||
|
);
|
||||||
|
expect(urlResponse.ok).toBe(true);
|
||||||
|
expect(urlResponse.headers.get('cache-control')).toBe('no-store');
|
||||||
|
const body = await urlResponse.json() as {
|
||||||
|
url: string;
|
||||||
|
file: string;
|
||||||
|
csp: string;
|
||||||
|
iframeSandbox: string;
|
||||||
|
opaqueOrigin: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.file).toBe('pages/index.html');
|
||||||
|
expect(body.url).toContain(`/api/projects/${projectId}/preview/`);
|
||||||
|
expect(body.url).toMatch(/\/preview\/[A-Za-z0-9_-]{8,128}\/pages\/index\.html$/u);
|
||||||
|
expect(body.iframeSandbox).toBe('allow-scripts allow-forms');
|
||||||
|
expect(body.iframeSandbox).not.toContain('allow-same-origin');
|
||||||
|
expect(body.csp).toContain('sandbox allow-scripts allow-forms');
|
||||||
|
expect(body.csp).toContain("connect-src 'none'");
|
||||||
|
expect(body.csp).not.toContain('allow-same-origin');
|
||||||
|
expect(body.opaqueOrigin).toBe(true);
|
||||||
|
|
||||||
|
const previewResponse = await fetch(`${baseUrl}${body.url}`, {
|
||||||
|
headers: { Origin: 'null' },
|
||||||
|
});
|
||||||
|
expect(previewResponse.status).toBe(200);
|
||||||
|
expect(previewResponse.headers.get('access-control-allow-origin')).toBe('*');
|
||||||
|
expect(previewResponse.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect(previewResponse.headers.get('x-content-type-options')).toBe('nosniff');
|
||||||
|
const csp = previewResponse.headers.get('content-security-policy') ?? '';
|
||||||
|
expect(csp).toContain('sandbox allow-scripts allow-forms');
|
||||||
|
expect(csp).toContain("connect-src 'none'");
|
||||||
|
expect(csp).not.toContain('allow-same-origin');
|
||||||
|
expect(await previewResponse.text()).toContain('<title>Preview</title>');
|
||||||
|
|
||||||
|
const scope = body.url.match(/\/preview\/([^/]+)\//u)?.[1];
|
||||||
|
expect(scope).toBeTruthy();
|
||||||
|
const assetResponse = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${projectId}/preview/${scope}/styles/app.css`,
|
||||||
|
{ headers: { Origin: 'null' } },
|
||||||
|
);
|
||||||
|
expect(assetResponse.status).toBe(200);
|
||||||
|
expect(assetResponse.headers.get('access-control-allow-origin')).toBe('*');
|
||||||
|
expect(assetResponse.headers.get('content-type')).toContain('text/css');
|
||||||
|
expect(await assetResponse.text()).toContain('color: black');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid preview scopes and escaping preview-url paths', async () => {
|
||||||
|
const projectId = await createProject();
|
||||||
|
await writeProjectFile(projectId, 'index.html', '<!doctype html>');
|
||||||
|
|
||||||
|
const invalidScope = await fetch(`${baseUrl}/api/projects/${projectId}/preview/bad/index.html`);
|
||||||
|
expect(invalidScope.status).toBe(400);
|
||||||
|
|
||||||
|
const escapingPath = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('../index.html')}`,
|
||||||
|
);
|
||||||
|
expect(escapingPath.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -124,6 +124,95 @@ test('conversation latest run follows assistant message position', () => {
|
||||||
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
|
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('conversation summaries expose cumulative completed run duration', () => {
|
||||||
|
const db = createDb();
|
||||||
|
insertProject(db, {
|
||||||
|
id: 'project-duration',
|
||||||
|
name: 'project-duration',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
});
|
||||||
|
insertConversation(db, {
|
||||||
|
id: 'project-duration-conversation',
|
||||||
|
projectId: 'project-duration',
|
||||||
|
title: 'Duration test',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 4,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-duration-conversation', {
|
||||||
|
id: 'project-duration-first',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'first done',
|
||||||
|
runId: 'project-duration-first-run',
|
||||||
|
runStatus: 'succeeded',
|
||||||
|
startedAt: 10_000,
|
||||||
|
endedAt: 40_000,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-duration-conversation', {
|
||||||
|
id: 'project-duration-running',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'still running',
|
||||||
|
runId: 'project-duration-running-run',
|
||||||
|
runStatus: 'running',
|
||||||
|
startedAt: 45_000,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-duration-conversation', {
|
||||||
|
id: 'project-duration-second',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'second done',
|
||||||
|
runId: 'project-duration-second-run',
|
||||||
|
runStatus: 'failed',
|
||||||
|
startedAt: 50_000,
|
||||||
|
endedAt: 125_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = listConversations(db, 'project-duration')[0] as { totalDurationMs?: number };
|
||||||
|
const fetched = getConversation(db, 'project-duration-conversation') as { totalDurationMs?: number } | null;
|
||||||
|
|
||||||
|
assert.equal(listed.totalDurationMs, 105_000);
|
||||||
|
assert.equal(fetched?.totalDurationMs, 105_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('conversation summaries include usage-only terminal run durations', () => {
|
||||||
|
const db = createDb();
|
||||||
|
insertProject(db, {
|
||||||
|
id: 'project-usage-duration',
|
||||||
|
name: 'project-usage-duration',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
});
|
||||||
|
insertConversation(db, {
|
||||||
|
id: 'project-usage-duration-conversation',
|
||||||
|
projectId: 'project-usage-duration',
|
||||||
|
title: 'Usage duration test',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 4,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-usage-duration-conversation', {
|
||||||
|
id: 'project-usage-duration-imported',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'imported done',
|
||||||
|
runId: 'project-usage-duration-imported-run',
|
||||||
|
runStatus: 'succeeded',
|
||||||
|
events: [{ kind: 'usage', durationMs: 22_000 }],
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-usage-duration-conversation', {
|
||||||
|
id: 'project-usage-duration-timestamped',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'timestamped done',
|
||||||
|
runId: 'project-usage-duration-timestamped-run',
|
||||||
|
runStatus: 'succeeded',
|
||||||
|
startedAt: 30_000,
|
||||||
|
endedAt: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = listConversations(db, 'project-usage-duration')[0] as { totalDurationMs?: number };
|
||||||
|
const fetched = getConversation(db, 'project-usage-duration-conversation') as { totalDurationMs?: number } | null;
|
||||||
|
|
||||||
|
assert.equal(listed.totalDurationMs, 52_000);
|
||||||
|
assert.equal(fetched?.totalDurationMs, 52_000);
|
||||||
|
});
|
||||||
|
|
||||||
test('conversation listing batches latest run summaries for large projects', () => {
|
test('conversation listing batches latest run summaries for large projects', () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
insertProject(db, {
|
insertProject(db, {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
*/
|
*/
|
||||||
import type http from 'node:http';
|
import type http from 'node:http';
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { mkdir, readdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
@ -299,6 +299,644 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Project locations routes: GET, PUT, scan, and project creation under an
|
||||||
|
// external project location.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('project locations routes', () => {
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||||
|
url: string;
|
||||||
|
server: http.Server;
|
||||||
|
};
|
||||||
|
baseUrl = started.url;
|
||||||
|
server = started.server;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
const d = mkdtempSync(path.join(tmpdir(), 'od-proj-loc-routes-'));
|
||||||
|
tempDirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putProjectLocations(
|
||||||
|
locations: Array<{ id?: string; name?: string; path: string }>,
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`${baseUrl}/api/project-locations`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ locations }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putAppConfig(config: Record<string, unknown>): Promise<Response> {
|
||||||
|
return fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GET /api/project-locations returns built-in default plus empty external', async () => {
|
||||||
|
const resp = await fetch(`${baseUrl}/api/project-locations`);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
const body = (await resp.json()) as { locations: Array<{ id: string; name: string; builtIn?: boolean; path: string }> };
|
||||||
|
expect(body.locations).toHaveLength(1); // only default on fresh start
|
||||||
|
const loc0 = body.locations[0]!;
|
||||||
|
expect(loc0.id).toBe('default');
|
||||||
|
expect(loc0.builtIn).toBe(true);
|
||||||
|
expect(loc0.name).toBe('Open Design projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations creates external roots and GET returns them alongside default', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
const resp = await putProjectLocations([
|
||||||
|
{ id: 'ext-root', name: 'External', path: extDir },
|
||||||
|
]);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
const putBody = (await resp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
|
||||||
|
expect(putBody.locations).toHaveLength(2);
|
||||||
|
const putLoc0 = putBody.locations[0]!;
|
||||||
|
const putLoc1 = putBody.locations[1]!;
|
||||||
|
expect(putLoc0.id).toBe('default');
|
||||||
|
expect(putLoc1.id).toBe('ext-root');
|
||||||
|
expect(putLoc1.path).toBe(await realpath(extDir));
|
||||||
|
|
||||||
|
// GET returns the same
|
||||||
|
const getResp = await fetch(`${baseUrl}/api/project-locations`);
|
||||||
|
expect(getResp.status).toBe(200);
|
||||||
|
const getBody = (await getResp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
|
||||||
|
expect(getBody.locations).toHaveLength(2);
|
||||||
|
const getLoc0 = getBody.locations[0]!;
|
||||||
|
const getLoc1 = getBody.locations[1]!;
|
||||||
|
expect(getLoc0.id).toBe('default');
|
||||||
|
expect(getLoc1.id).toBe('ext-root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/project-locations/scan returns empty result when no manifests found', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'empty-ext', name: 'Empty', path: extDir }]);
|
||||||
|
|
||||||
|
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(scanResp.status).toBe(200);
|
||||||
|
const body = (await scanResp.json()) as {
|
||||||
|
scanned: number;
|
||||||
|
imported: unknown[];
|
||||||
|
existing: string[];
|
||||||
|
skipped: unknown[];
|
||||||
|
};
|
||||||
|
expect(body.scanned).toBe(0);
|
||||||
|
expect(body.imported).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/project-locations/scan imports manifest-backed project and skips on re-scan', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
// Create a project directory with a valid manifest
|
||||||
|
const projectDir = path.join(extDir, 'scan-test-proj');
|
||||||
|
const odDir = path.join(projectDir, '.open-design');
|
||||||
|
await mkdir(odDir, { recursive: true });
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
id: 'scan-test-proj',
|
||||||
|
name: 'Scanned Project',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(projectDir, '.open-design', 'project.json'),
|
||||||
|
JSON.stringify(manifest, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register the location
|
||||||
|
await putProjectLocations([{ id: 'scan-ext', name: 'Scan External', path: extDir }]);
|
||||||
|
|
||||||
|
// First scan: should import
|
||||||
|
const scan1 = await fetch(`${baseUrl}/api/project-locations/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(scan1.status).toBe(200);
|
||||||
|
const body1 = (await scan1.json()) as {
|
||||||
|
scanned: number;
|
||||||
|
imported: Array<{ id: string; name: string; metadata?: { baseDir?: string; importedFrom?: string } }>;
|
||||||
|
existing: string[];
|
||||||
|
skipped: unknown[];
|
||||||
|
};
|
||||||
|
expect(body1.scanned).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(body1.imported).toHaveLength(1);
|
||||||
|
const imported0 = body1.imported[0]!;
|
||||||
|
expect(imported0.id).toBe('scan-test-proj');
|
||||||
|
expect(imported0.name).toBe('Scanned Project');
|
||||||
|
// The imported project should have metadata pointing at the external dir
|
||||||
|
// (ensureProjectLocation calls realpath which resolves /var -> /private/var on macOS)
|
||||||
|
expect(imported0.metadata?.baseDir).toBe(await realpath(projectDir));
|
||||||
|
expect(imported0.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(body1.existing).toEqual([]);
|
||||||
|
|
||||||
|
// Second scan: project already exists, should be in "existing"
|
||||||
|
const scan2 = await fetch(`${baseUrl}/api/project-locations/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(scan2.status).toBe(200);
|
||||||
|
const body2 = (await scan2.json()) as {
|
||||||
|
scanned: number;
|
||||||
|
imported: unknown[];
|
||||||
|
existing: string[];
|
||||||
|
};
|
||||||
|
expect(body2.imported).toEqual([]);
|
||||||
|
expect(body2.existing).toEqual(['scan-test-proj']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId creates project under external root and writes .open-design/project.json', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
// Register an external location
|
||||||
|
await putProjectLocations([{ id: 'create-ext', name: 'Create External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `ext-proj-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'External Project',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
projectLocationId: 'create-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const createBody = (await createResp.json()) as {
|
||||||
|
project: { id: string; metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
|
||||||
|
};
|
||||||
|
expect(createBody.project.id).toBe(projectId);
|
||||||
|
expect(createBody.project.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(createBody.project.metadata?.projectLocationId).toBe('create-ext');
|
||||||
|
|
||||||
|
// The project should be under <extDir>/<projectId> (ensureProjectLocation realpaths)
|
||||||
|
const expectedProjectDir = await realpath(path.join(extDir, projectId));
|
||||||
|
expect(createBody.project.metadata?.baseDir).toBe(expectedProjectDir);
|
||||||
|
|
||||||
|
// Verify .open-design/project.json was written
|
||||||
|
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
|
||||||
|
const manifestRaw = await import('node:fs/promises').then((m) => m.readFile(manifestPath, 'utf8'));
|
||||||
|
const manifest = JSON.parse(manifestRaw);
|
||||||
|
expect(manifest.schemaVersion).toBe(1);
|
||||||
|
expect(manifest.id).toBe(projectId);
|
||||||
|
expect(manifest.name).toBe('External Project');
|
||||||
|
|
||||||
|
// GET /api/projects/:id resolvedDir equals the external project dir
|
||||||
|
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
||||||
|
expect(detailResp.status).toBe(200);
|
||||||
|
const detail = (await detailResp.json()) as { resolvedDir: string };
|
||||||
|
expect(detail.resolvedDir).toBe(expectedProjectDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects uses the configured default project location when no location is supplied', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
const locationId = 'default-create-location';
|
||||||
|
await putProjectLocations([{ id: locationId, name: 'Default External', path: extDir }]);
|
||||||
|
const cfgResp = await putAppConfig({ defaultProjectLocationId: locationId });
|
||||||
|
expect(cfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
const projectId = `default-location-project-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Default location project',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const body = (await createResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; projectLocationId?: string; importedFrom?: string } };
|
||||||
|
};
|
||||||
|
expect(body.project.metadata?.projectLocationId).toBe(locationId);
|
||||||
|
expect(body.project.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(body.project.metadata?.baseDir).toBe(await realpath(path.join(extDir, projectId)));
|
||||||
|
|
||||||
|
await putAppConfig({ defaultProjectLocationId: null });
|
||||||
|
await putProjectLocations([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects falls back to built-in storage when configured default location is unavailable', async () => {
|
||||||
|
await putProjectLocations([]);
|
||||||
|
const cfgResp = await putAppConfig({ defaultProjectLocationId: 'missing-location' });
|
||||||
|
expect(cfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
const projectId = `missing-default-project-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Missing default project',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const body = (await createResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; projectLocationId?: string } };
|
||||||
|
};
|
||||||
|
expect(body.project.metadata?.baseDir).toBeUndefined();
|
||||||
|
expect(body.project.metadata?.projectLocationId).toBeUndefined();
|
||||||
|
|
||||||
|
await putAppConfig({ defaultProjectLocationId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/projects/:id preserves project-location provenance with baseDir', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'patch-ext', name: 'Patch External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `ext-patch-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Patch External Project',
|
||||||
|
projectLocationId: 'patch-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const createBody = (await createResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ metadata: { kind: 'prototype', skipDiscoveryBrief: true } }),
|
||||||
|
});
|
||||||
|
expect(patchResp.status).toBe(200);
|
||||||
|
const patchBody = (await patchResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string; skipDiscoveryBrief?: boolean } };
|
||||||
|
};
|
||||||
|
expect(patchBody.project.metadata?.baseDir).toBe(createBody.project.metadata?.baseDir);
|
||||||
|
expect(patchBody.project.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(patchBody.project.metadata?.projectLocationId).toBe('patch-ext');
|
||||||
|
expect(patchBody.project.metadata?.skipDiscoveryBrief).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with unknown projectLocationId returns 400', async () => {
|
||||||
|
const projectId = `bad-loc-${Date.now()}`;
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Bad Location Project',
|
||||||
|
projectLocationId: 'nonexistent-location-id',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/project location/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with invalid designSystemId does not create external project directory', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'invalid-ds-ext', name: 'Invalid DS External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `invalid-ds-${Date.now()}`;
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Invalid design system project',
|
||||||
|
designSystemId: `missing-design-system-${Date.now()}`,
|
||||||
|
projectLocationId: 'invalid-ds-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string } };
|
||||||
|
expect(body.error?.code).toBe('DESIGN_SYSTEM_NOT_FOUND');
|
||||||
|
await expect(readdir(extDir)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations rejects non-array locations body', async () => {
|
||||||
|
const resp = await fetch(`${baseUrl}/api/project-locations`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ locations: 'not-an-array' }),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Security boundaries — see #451 (project-locations) for context.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId rejects unsafe id "."', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'sec-ext', name: 'Security External', path: extDir }]);
|
||||||
|
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: '.',
|
||||||
|
name: 'Dot Project',
|
||||||
|
projectLocationId: 'sec-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/invalid project id/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId rejects unsafe id ".."', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'sec-ext2', name: 'Security External 2', path: extDir }]);
|
||||||
|
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: '..',
|
||||||
|
name: 'DotDot Project',
|
||||||
|
projectLocationId: 'sec-ext2',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/invalid project id/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId rejects when target path already exists as a symlink', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'sym-ext', name: 'Symlink External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `symlink-proj-${Date.now()}`;
|
||||||
|
const realTargetDir = path.join(extDir, 'real-target');
|
||||||
|
await mkdir(realTargetDir, { recursive: true });
|
||||||
|
|
||||||
|
// Pre-create a symlink at <extDir>/<projectId> pointing to another directory
|
||||||
|
const symlinkPath = path.join(extDir, projectId);
|
||||||
|
await symlink(realTargetDir, symlinkPath);
|
||||||
|
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Symlink Project',
|
||||||
|
projectLocationId: 'sym-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations rejects a root overlapping the daemon projects dir', async () => {
|
||||||
|
const dataDir = process.env.OD_DATA_DIR;
|
||||||
|
if (!dataDir) throw new Error('OD_DATA_DIR required for daemon route tests');
|
||||||
|
const projectsDir = path.join(dataDir, 'projects');
|
||||||
|
|
||||||
|
const canonicalProjectsDir = await realpath(projectsDir);
|
||||||
|
|
||||||
|
const resp = await putProjectLocations([
|
||||||
|
{ id: 'overlap-projects', name: 'Overlap Projects', path: canonicalProjectsDir },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/cannot overlap|daemon data/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations rejects filesystem root "/" via isBlocked check', async () => {
|
||||||
|
// isBlocked in linked-dirs.ts rejects the filesystem root.
|
||||||
|
const resp = await putProjectLocations([
|
||||||
|
{ id: 'root-loc', name: 'Root', path: '/' },
|
||||||
|
]);
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('app-config bypass: PUT /api/app-config persists invalid path but GET /api/project-locations does not expose it', async () => {
|
||||||
|
// Persist a projectLocations entry with a system-protected path ('/') via
|
||||||
|
// the generic PUT /api/app-config route, which only validates format, not
|
||||||
|
// safety. The GET /api/project-locations route must filter it out because
|
||||||
|
// configuredProjectLocations() runs validateLinkedDirs + locationOverlapsDaemonData.
|
||||||
|
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectLocations: [
|
||||||
|
{ id: 'bad-root', name: 'Bad Root', path: '/' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(appCfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
// Verify the persisted config (read back) contains the entry (format validation passed)
|
||||||
|
const readCfgResp = await fetch(`${baseUrl}/api/app-config`);
|
||||||
|
expect(readCfgResp.status).toBe(200);
|
||||||
|
const cfgBody = (await readCfgResp.json()) as {
|
||||||
|
config: { projectLocations?: Array<{ id: string; path: string }> };
|
||||||
|
};
|
||||||
|
// The entry was normalized and persisted
|
||||||
|
const locs = cfgBody.config.projectLocations;
|
||||||
|
expect(locs).toBeDefined();
|
||||||
|
expect(locs!.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// But GET /api/project-locations must NOT expose it
|
||||||
|
const locResp = await fetch(`${baseUrl}/api/project-locations`);
|
||||||
|
expect(locResp.status).toBe(200);
|
||||||
|
const locBody = (await locResp.json()) as {
|
||||||
|
locations: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
const ids = locBody.locations.map((l) => l.id);
|
||||||
|
expect(ids).toContain('default'); // built-in always present
|
||||||
|
// The invalid location must not appear
|
||||||
|
expect(ids).not.toContain('bad-root');
|
||||||
|
|
||||||
|
// Clean up: remove the invalid projectLocations
|
||||||
|
await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectLocations: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('app-config bypass: POST /api/projects with invalid persisted root id returns 400 unknown project location', async () => {
|
||||||
|
// Persist a projectLocations entry with '/' via app-config.
|
||||||
|
// The auto-generated id follows the loc_<base64url> pattern.
|
||||||
|
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectLocations: [
|
||||||
|
{ id: 'evil-root', name: 'Evil Root', path: '/' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(appCfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
// Try to create a project under this location id. Since configuredProjectLocations
|
||||||
|
// filters it, the lookup returns nothing → 400 "unknown project location".
|
||||||
|
const projectId = `evil-proj-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Evil Project',
|
||||||
|
projectLocationId: 'evil-root',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(400);
|
||||||
|
const body = (await createResp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/unknown project location/i);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectLocations: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing an external location hides its projects but preserves DB history and disk files for re-scan', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
const locationId = 'unreg-loc';
|
||||||
|
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
|
||||||
|
|
||||||
|
// Create a project under this external location
|
||||||
|
const projectId = `unreg-proj-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Project To Unregister',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
projectLocationId: locationId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const createBody = (await createResp.json()) as {
|
||||||
|
project: { id: string };
|
||||||
|
conversationId: string;
|
||||||
|
};
|
||||||
|
expect(createBody.project.id).toBe(projectId);
|
||||||
|
|
||||||
|
const messageId = `msg-${Date.now()}`;
|
||||||
|
const messageResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages/${messageId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: 'user',
|
||||||
|
content: 'restore this conversation after location re-add',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(messageResp.status).toBe(200);
|
||||||
|
|
||||||
|
// Confirm the project is listed
|
||||||
|
const listBefore = await fetch(`${baseUrl}/api/projects`);
|
||||||
|
expect(listBefore.status).toBe(200);
|
||||||
|
const beforeBody = (await listBefore.json()) as { projects: Array<{ id: string }> };
|
||||||
|
expect(beforeBody.projects.some((p) => p.id === projectId)).toBe(true);
|
||||||
|
|
||||||
|
// The project directory and manifest should exist on disk
|
||||||
|
const expectedProjectDir = await realpath(path.join(extDir, projectId));
|
||||||
|
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
|
||||||
|
const manifestBefore = await readFile(manifestPath, 'utf8');
|
||||||
|
expect(JSON.parse(manifestBefore).id).toBe(projectId);
|
||||||
|
|
||||||
|
// Remove the external location: PUT empty locations so the location is dropped.
|
||||||
|
// This is an unmount/hide operation, not a destructive project delete.
|
||||||
|
const removeResp = await putProjectLocations([]);
|
||||||
|
expect(removeResp.status).toBe(200);
|
||||||
|
const removeBody = (await removeResp.json()) as {
|
||||||
|
locations: Array<{ id: string }>;
|
||||||
|
removedProjectIds?: string[];
|
||||||
|
};
|
||||||
|
// The response must include removedProjectIds with our project
|
||||||
|
expect(removeBody.removedProjectIds).toBeDefined();
|
||||||
|
expect(removeBody.removedProjectIds).toContain(projectId);
|
||||||
|
// Only the built-in default location should remain
|
||||||
|
expect(removeBody.locations).toHaveLength(1);
|
||||||
|
expect(removeBody.locations[0]!.id).toBe('default');
|
||||||
|
|
||||||
|
// The project should no longer appear in GET /api/projects
|
||||||
|
const listAfter = await fetch(`${baseUrl}/api/projects`);
|
||||||
|
expect(listAfter.status).toBe(200);
|
||||||
|
const afterBody = (await listAfter.json()) as { projects: Array<{ id: string }> };
|
||||||
|
expect(afterBody.projects.some((p) => p.id === projectId)).toBe(false);
|
||||||
|
|
||||||
|
// GET /api/projects/:id should return 404 while the location is unmounted.
|
||||||
|
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
||||||
|
expect(detailResp.status).toBe(404);
|
||||||
|
|
||||||
|
// The on-disk project directory and manifest must still be intact
|
||||||
|
const manifestAfter = await readFile(manifestPath, 'utf8');
|
||||||
|
expect(JSON.parse(manifestAfter).id).toBe(projectId);
|
||||||
|
|
||||||
|
// Re-add the same base and scan: the existing DB row should be revealed,
|
||||||
|
// not recreated from only the manifest, so conversation history survives.
|
||||||
|
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
|
||||||
|
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, { method: 'POST' });
|
||||||
|
expect(scanResp.status).toBe(200);
|
||||||
|
const scanBody = (await scanResp.json()) as { imported: Array<{ id: string }>; existing: string[] };
|
||||||
|
expect(scanBody.imported.some((p) => p.id === projectId)).toBe(false);
|
||||||
|
expect(scanBody.existing).toContain(projectId);
|
||||||
|
|
||||||
|
const listReadded = await fetch(`${baseUrl}/api/projects`);
|
||||||
|
expect(listReadded.status).toBe(200);
|
||||||
|
const readdedBody = (await listReadded.json()) as { projects: Array<{ id: string }> };
|
||||||
|
expect(readdedBody.projects.some((p) => p.id === projectId)).toBe(true);
|
||||||
|
|
||||||
|
const messagesResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages`);
|
||||||
|
expect(messagesResp.status).toBe(200);
|
||||||
|
const messagesBody = (await messagesResp.json()) as { messages: Array<{ id: string; content: string }> };
|
||||||
|
expect(messagesBody.messages).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: messageId,
|
||||||
|
content: 'restore this conversation after location re-add',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||||
const previous = process.env.OD_SANDBOX_MODE;
|
const previous = process.env.OD_SANDBOX_MODE;
|
||||||
process.env.OD_SANDBOX_MODE = '1';
|
process.env.OD_SANDBOX_MODE = '1';
|
||||||
|
|
|
||||||
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal file
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeRunToolBundleForRun,
|
||||||
|
parseRunToolBundleForRequest,
|
||||||
|
resolveExternalMcpServersForRun,
|
||||||
|
summarizeRunToolBundle,
|
||||||
|
validateRunToolBundleForAgent,
|
||||||
|
} from '../src/run-tool-bundle.js';
|
||||||
|
|
||||||
|
describe('run-scoped tool bundles', () => {
|
||||||
|
it('sanitizes MCP servers onto the run and redacts spawn-only details in summaries', () => {
|
||||||
|
const bundle = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'local-tools',
|
||||||
|
label: 'Local tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js', '--token=secret'],
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remote-tools',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
headers: { Authorization: 'Bearer secret' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '../bad',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bundle.mcpServers).toHaveLength(2);
|
||||||
|
expect(bundle.mcpServers[0]).toMatchObject({
|
||||||
|
id: 'local-tools',
|
||||||
|
command: 'node',
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = summarizeRunToolBundle(bundle);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'local-tools',
|
||||||
|
label: 'Local tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remote-tools',
|
||||||
|
transport: 'http',
|
||||||
|
enabled: true,
|
||||||
|
authMode: 'oauth',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(summary)).not.toContain('secret');
|
||||||
|
expect(JSON.stringify(summary)).not.toContain('server.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only run-scoped MCP servers in sandbox mode', () => {
|
||||||
|
const persistedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'persisted',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://persisted.example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
const runScopedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-only',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['run-tool.js'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
|
||||||
|
const selection = resolveExternalMcpServersForRun({
|
||||||
|
persistedServers,
|
||||||
|
runScopedServers,
|
||||||
|
sandboxMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.enabledServers.map((server) => server.id)).toEqual(['run-only']);
|
||||||
|
expect([...selection.persistedTokenServerIds]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed run-scoped MCP server entries for request payloads', () => {
|
||||||
|
expect(parseRunToolBundleForRequest('bad')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle must be an object',
|
||||||
|
});
|
||||||
|
expect(parseRunToolBundleForRequest({ mcpServers: 'bad' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle.mcpServers must be an array',
|
||||||
|
});
|
||||||
|
expect(parseRunToolBundleForRequest({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'missing-command',
|
||||||
|
transport: 'stdio',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle.mcpServers[0] is invalid',
|
||||||
|
});
|
||||||
|
expect(parseRunToolBundleForRequest({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'dup',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dup',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle.mcpServers[1] duplicates server id "dup"',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a run-scoped server override persisted config without inheriting persisted tokens', () => {
|
||||||
|
const persistedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://persisted.example.test/mcp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'persisted-only',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://persisted-only.example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
const runScopedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://run.example.test/mcp',
|
||||||
|
headers: { Authorization: 'Bearer run-token' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
|
||||||
|
const selection = resolveExternalMcpServersForRun({
|
||||||
|
persistedServers,
|
||||||
|
runScopedServers,
|
||||||
|
sandboxMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.enabledServers).toHaveLength(2);
|
||||||
|
expect(selection.enabledServers.find((server) => server.id === 'shared')).toMatchObject({
|
||||||
|
url: 'https://run.example.test/mcp',
|
||||||
|
});
|
||||||
|
expect([...selection.persistedTokenServerIds]).toEqual(['persisted-only']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bundles for runtimes that cannot receive the requested servers', () => {
|
||||||
|
const stdioOnly = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const remote = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'remote',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(stdioOnly, {
|
||||||
|
id: 'codex',
|
||||||
|
name: 'Codex CLI',
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'Codex CLI (codex) does not support run-scoped MCP tool bundles',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(remote, {
|
||||||
|
id: 'hermes',
|
||||||
|
name: 'Hermes',
|
||||||
|
externalMcpInjection: 'acp-merge',
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
'toolBundle.mcpServers[0] uses http transport, but Hermes (hermes) only supports stdio run-scoped MCP servers',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(remote, {
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
externalMcpInjection: 'claude-mcp-json',
|
||||||
|
})).toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(remote, {
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
externalMcpInjection: 'claude-mcp-json',
|
||||||
|
}, {
|
||||||
|
deliveryTarget: 'external-project',
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
'Claude Code (claude) receives run-scoped MCP tool bundles through project .mcp.json, ' +
|
||||||
|
'so toolBundle requires a daemon-managed project',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -80,6 +80,45 @@ describe('chat run service shutdown', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores a run-scoped tool bundle and returns a redacted status summary', () => {
|
||||||
|
const runs = createRuns();
|
||||||
|
const run = runs.create({
|
||||||
|
projectId: 'project-1',
|
||||||
|
conversationId: 'conv-a',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js', '--token=secret'],
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
expect(run.toolBundle.mcpServers).toHaveLength(1);
|
||||||
|
expect(run.toolBundle.mcpServers[0]).toMatchObject({
|
||||||
|
id: 'run-tools',
|
||||||
|
command: 'node',
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = runs.statusBody(run);
|
||||||
|
expect(status.toolBundle).toEqual({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(status)).not.toContain('secret');
|
||||||
|
expect(JSON.stringify(status)).not.toContain('server.js');
|
||||||
|
});
|
||||||
|
|
||||||
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
|
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
|
||||||
const runs = createRuns();
|
const runs = createRuns();
|
||||||
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
|
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
|
||||||
|
|
|
||||||
|
|
@ -957,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
|
||||||
assert.equal(env.PATH, '/usr/bin');
|
assert.equal(env.PATH, '/usr/bin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
|
||||||
|
const env = spawnEnvForAgent(
|
||||||
|
'claude',
|
||||||
|
{
|
||||||
|
ANTHROPIC_API_KEY: 'sk-openclaude',
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ resolvedBin: '/tools/openclaude' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
|
||||||
|
assert.equal(env.PATH, '/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
|
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
|
||||||
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
|
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
|
||||||
const env = spawnEnvForAgent(agentId, {
|
const env = spawnEnvForAgent(agentId, {
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,16 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
|
||||||
expect(out).not.toContain('Reference prompt template');
|
expect(out).not.toContain('Reference prompt template');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('non-media dispatch hint includes fal-ai/* passthrough instruction', () => {
|
||||||
|
const out = composeSystemPrompt({
|
||||||
|
metadata: { kind: 'prototype' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toContain('## Media generation (if asked)');
|
||||||
|
expect(out).toContain('fal-ai/*');
|
||||||
|
expect(out).toContain('pass it through as-is without substitution');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders without source attribution when the source field is missing', () => {
|
it('renders without source attribution when the source field is missing', () => {
|
||||||
const { source: _omit, ...withoutSource } = baseSummary;
|
const { source: _omit, ...withoutSource } = baseSummary;
|
||||||
const out = composeSystemPrompt({
|
const out = composeSystemPrompt({
|
||||||
|
|
@ -420,8 +430,8 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(out).toContain('`media generate` treats the handoff as');
|
expect(out).toContain('always exits 0');
|
||||||
expect(out).toContain('exit `0` so the first dispatch does not look like a failed shell call');
|
expect(out).toContain('as a handoff signal');
|
||||||
expect(out).toContain('`"$OD_NODE_BIN" "$OD_BIN" media generate` exits `0`');
|
expect(out).toContain('`"$OD_NODE_BIN" "$OD_BIN" media generate` exits `0`');
|
||||||
expect(out).toContain('either `file` or `taskId`');
|
expect(out).toContain('either `file` or `taskId`');
|
||||||
expect(out).toContain('`2` from `media wait` is not a failure');
|
expect(out).toContain('`2` from `media wait` is not a failure');
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* Shared skill row used on `/skills/`, `/skills/mode/<slug>/`,
|
|
||||||
* `/skills/scenario/<slug>/`, and any future faceted view.
|
|
||||||
*
|
|
||||||
* Renders a `<li class="catalog-row catalog-row-skill">` with the
|
|
||||||
* canonical 5-column grid (index, thumb, body, meta, arrow). Centralizes
|
|
||||||
* the markup so all faceted views stay visually identical to the
|
|
||||||
* unfiltered index.
|
|
||||||
*/
|
|
||||||
import type { SkillRecord } from '../_lib/catalog';
|
|
||||||
import { localeFromPath, localizedHref } from '../i18n';
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
skill: SkillRecord;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { skill, index } = Astro.props;
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
|
|
||||||
// Catalog row thumbs are tiny (~130×80 rendered, single-format PNGs)
|
|
||||||
// so we deliberately bypass the precise IntersectionObserver pipeline.
|
|
||||||
// On long lists like /skills/instructions/ (96 rows) the observer's
|
|
||||||
// swap latency stranded mid-page rows on the SVG placeholder during
|
|
||||||
// fast scrolls. Native lazy loading (the browser's own 1250-3000px
|
|
||||||
// lookahead) keeps the upcoming rows pre-fetched without the
|
|
||||||
// observer round-trip; only the first three rows go eager so they
|
|
||||||
// paint immediately on first paint instead of waiting for the
|
|
||||||
// browser's lazy queue.
|
|
||||||
const eager = index < 3;
|
|
||||||
---
|
|
||||||
|
|
||||||
<li class="catalog-row catalog-row-skill">
|
|
||||||
<a href={href(`/skills/${skill.slug}/`)}>
|
|
||||||
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
|
|
||||||
<span class="row-thumb">
|
|
||||||
{skill.previewUrl ? (
|
|
||||||
<img
|
|
||||||
src={skill.previewUrl}
|
|
||||||
alt=""
|
|
||||||
loading={eager ? 'eager' : 'lazy'}
|
|
||||||
decoding="async"
|
|
||||||
fetchpriority={eager ? 'high' : 'auto'}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span class="row-thumb-empty" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span class="row-body">
|
|
||||||
<span class="row-name">{skill.name}</span>
|
|
||||||
<span class="row-desc">{skill.description}</span>
|
|
||||||
</span>
|
|
||||||
<span class="row-meta">
|
|
||||||
{skill.modeLabel && <span class="meta-tag">{skill.modeLabel}</span>}
|
|
||||||
{skill.scenarioLabel && <span class="meta-tag muted">{skill.scenarioLabel}</span>}
|
|
||||||
{skill.platformLabel && <span class="meta-tag muted">{skill.platformLabel}</span>}
|
|
||||||
</span>
|
|
||||||
<span class="row-arrow" aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
---
|
---
|
||||||
/*
|
/*
|
||||||
* Shared system card used on `/systems/` and
|
* Shared system card used on `/plugins/systems/`. Displays palette
|
||||||
* `/systems/category/<slug>/`. Displays palette swatches, name,
|
* swatches, name, category, and tagline as a clickable card.
|
||||||
* category, and tagline as a clickable card.
|
*
|
||||||
|
* The card links to `/systems/<slug>/`, which `public/_redirects`
|
||||||
|
* 301s to the bundled-plugin detail (`/plugins/design-system-<slug>/`)
|
||||||
|
* for the 142 systems that have one, and degrades the 8 without a
|
||||||
|
* detail page to `/plugins/systems/`. Linking through the redirect
|
||||||
|
* (rather than hard-coding `design-system-<slug>`) keeps those 8 from
|
||||||
|
* pointing at a non-existent detail page.
|
||||||
*/
|
*/
|
||||||
import type { SystemRecord } from '../_lib/catalog';
|
import type { SystemRecord } from '../_lib/catalog';
|
||||||
import { localeFromPath, localizedHref } from '../i18n';
|
import { localeFromPath, localizedHref } from '../i18n';
|
||||||
|
|
|
||||||
|
|
@ -222,9 +222,9 @@ const INFO_PAGE_COPY: Partial<Record<LandingLocaleCode, InfoPageCopy>> = {
|
||||||
{ label: 'Community', name: 'Discord' },
|
{ label: 'Community', name: 'Discord' },
|
||||||
{ label: 'Documentation', name: 'GitHub README' },
|
{ label: 'Documentation', name: 'GitHub README' },
|
||||||
{ label: 'License', name: 'Apache-2.0' },
|
{ label: 'License', name: 'Apache-2.0' },
|
||||||
{ label: 'Skills catalog', name: '/skills/' },
|
{ label: 'Skills catalog', name: '/plugins/skills/' },
|
||||||
{ label: 'Systems catalog', name: '/systems/' },
|
{ label: 'Systems catalog', name: '/plugins/systems/' },
|
||||||
{ label: 'Templates catalog', name: '/templates/' },
|
{ label: 'Templates catalog', name: '/plugins/templates/' },
|
||||||
],
|
],
|
||||||
aliasesTitle: 'Naming & aliases',
|
aliasesTitle: 'Naming & aliases',
|
||||||
aliasesLead:
|
aliasesLead:
|
||||||
|
|
@ -538,9 +538,9 @@ INFO_PAGE_COPY.zh = {
|
||||||
{ label: '社区', name: 'Discord' },
|
{ label: '社区', name: 'Discord' },
|
||||||
{ label: '文档', name: 'GitHub README' },
|
{ label: '文档', name: 'GitHub README' },
|
||||||
{ label: '许可证', name: 'Apache-2.0' },
|
{ label: '许可证', name: 'Apache-2.0' },
|
||||||
{ label: 'Skill 目录', name: '/skills/' },
|
{ label: 'Skill 目录', name: '/plugins/skills/' },
|
||||||
{ label: '系统目录', name: '/systems/' },
|
{ label: '系统目录', name: '/plugins/systems/' },
|
||||||
{ label: '模板目录', name: '/templates/' },
|
{ label: '模板目录', name: '/plugins/templates/' },
|
||||||
],
|
],
|
||||||
aliasesTitle: '命名与别名',
|
aliasesTitle: '命名与别名',
|
||||||
aliasesLead: '不同工具、受众和语言环境里,这个项目会以几种方式被搜索和书写:',
|
aliasesLead: '不同工具、受众和语言环境里,这个项目会以几种方式被搜索和书写:',
|
||||||
|
|
@ -1027,9 +1027,9 @@ const sourceNames = [
|
||||||
'Discord',
|
'Discord',
|
||||||
'GitHub README',
|
'GitHub README',
|
||||||
'Apache-2.0',
|
'Apache-2.0',
|
||||||
'/skills/',
|
'/plugins/skills/',
|
||||||
'/systems/',
|
'/plugins/systems/',
|
||||||
'/templates/',
|
'/plugins/templates/',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const aliasLabels = [
|
const aliasLabels = [
|
||||||
|
|
|
||||||
|
|
@ -730,23 +730,23 @@ export default function Page({
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className='pills' data-reveal='right'>
|
<div className='pills' data-reveal='right'>
|
||||||
<a className='pill active' href={href('/skills/')}>
|
<a className='pill active' href={href('/plugins/skills/')}>
|
||||||
{home.labs.pills.all}
|
{home.labs.pills.all}
|
||||||
<span className='count'>{skills}</span>
|
<span className='count'>{skills}</span>
|
||||||
</a>
|
</a>
|
||||||
<a className='pill' href={href('/skills/mode/prototype/')}>
|
<a className='pill' href={href('/plugins/templates/')}>
|
||||||
{home.labs.pills.prototype}
|
{home.labs.pills.prototype}
|
||||||
<span className='count'>{prototypeCount}</span>
|
<span className='count'>{prototypeCount}</span>
|
||||||
</a>
|
</a>
|
||||||
<a className='pill' href={href('/skills/mode/deck/')}>
|
<a className='pill' href={href('/plugins/templates/')}>
|
||||||
{home.labs.pills.deck}
|
{home.labs.pills.deck}
|
||||||
<span className='count'>{deckCount}</span>
|
<span className='count'>{deckCount}</span>
|
||||||
</a>
|
</a>
|
||||||
<a className='pill' href={href('/skills/')}>
|
<a className='pill' href={href('/plugins/templates/')}>
|
||||||
{home.labs.pills.mobile}
|
{home.labs.pills.mobile}
|
||||||
<span className='count'>{mobileCount}</span>
|
<span className='count'>{mobileCount}</span>
|
||||||
</a>
|
</a>
|
||||||
<a className='pill' href={href('/skills/')}>
|
<a className='pill' href={href('/plugins/templates/')}>
|
||||||
{home.labs.pills.office}
|
{home.labs.pills.office}
|
||||||
<span className='count'>—</span>
|
<span className='count'>—</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -839,7 +839,7 @@ export default function Page({
|
||||||
{home.labs.foot(skills)}
|
{home.labs.foot(skills)}
|
||||||
{NBSP}·{NBSP}
|
{NBSP}·{NBSP}
|
||||||
<a
|
<a
|
||||||
href={href('/skills/')}
|
href={href('/plugins/skills/')}
|
||||||
className='library-link'
|
className='library-link'
|
||||||
style={{ color: 'var(--coral)' }}
|
style={{ color: 'var(--coral)' }}
|
||||||
>
|
>
|
||||||
|
|
@ -953,7 +953,7 @@ export default function Page({
|
||||||
{home.work.titleSuffix}
|
{home.work.titleSuffix}
|
||||||
<span className='dot'>.</span>
|
<span className='dot'>.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<a className='work-link' href={href('/skills/')}>
|
<a className='work-link' href={href('/plugins/skills/')}>
|
||||||
{home.work.viewAll(skills)}
|
{home.work.viewAll(skills)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1325,17 +1325,17 @@ export default function Page({
|
||||||
<h5>{home.footer.columns.library}</h5>
|
<h5>{home.footer.columns.library}</h5>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href={href('/skills/')}>
|
<a href={href('/plugins/skills/')}>
|
||||||
{home.footer.libraryLinks.skills(skills)}
|
{home.footer.libraryLinks.skills(skills)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={href('/systems/')}>
|
<a href={href('/plugins/systems/')}>
|
||||||
{home.footer.libraryLinks.systems(systems)}
|
{home.footer.libraryLinks.systems(systems)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={href('/templates/')}>
|
<a href={href('/plugins/templates/')}>
|
||||||
{home.footer.libraryLinks.templates}
|
{home.footer.libraryLinks.templates}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,7 @@
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import Layout from '../../_components/sub-page-layout.astro';
|
import Layout from '../../_components/sub-page-layout.astro';
|
||||||
import type { HeaderProps } from '../../_components/header';
|
import type { HeaderProps } from '../../_components/header';
|
||||||
import LazyImg from '../../_components/lazy-img.astro';
|
import { getCraftRecords } from '../../_lib/catalog';
|
||||||
import {
|
|
||||||
getCraftRecords,
|
|
||||||
getSkillModeIndex,
|
|
||||||
getSkillRecords,
|
|
||||||
getSkillScenarioIndex,
|
|
||||||
getSystemCategoryIndex,
|
|
||||||
getSystemRecords,
|
|
||||||
getTemplateRecords,
|
|
||||||
tally,
|
|
||||||
} from '../../_lib/catalog';
|
|
||||||
import {
|
import {
|
||||||
PREFIXED_LOCALES,
|
PREFIXED_LOCALES,
|
||||||
getCopy,
|
getCopy,
|
||||||
|
|
@ -23,31 +13,17 @@ import {
|
||||||
import '../../globals.css';
|
import '../../globals.css';
|
||||||
import '../../sub-pages.css';
|
import '../../sub-pages.css';
|
||||||
|
|
||||||
// Localized routing only generates listing/index pages. Detail pages
|
// Localized routing only generates the `craft` and `blog` listing pages.
|
||||||
// (individual skills, posts, templates, …) stay at canonical English
|
// Detail pages (individual posts, craft items, …) stay at canonical
|
||||||
// URLs to keep the static build bounded; the localized chrome links
|
// English URLs to keep the static build bounded; the localized chrome
|
||||||
// straight to those canonical detail URLs.
|
// links straight to those canonical detail URLs.
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const skillModes = await getSkillModeIndex();
|
// The skills / systems / templates catalogs moved under `/plugins/*`.
|
||||||
const skillScenarios = await getSkillScenarioIndex();
|
// Their old localized listings are now 301'd by `public/_redirects`,
|
||||||
const systemCategories = await getSystemCategoryIndex();
|
// so this catch-all only renders the localized `craft` and `blog`
|
||||||
|
// listings. Plugins itself is generated via short-code wrappers under
|
||||||
const paths = [
|
// `app/pages/[locale]/plugins/`, so it does NOT participate here.
|
||||||
'skills',
|
const paths = ['craft', 'blog'];
|
||||||
'systems',
|
|
||||||
'craft',
|
|
||||||
'templates',
|
|
||||||
'blog',
|
|
||||||
// Plugins library is generated via short-code wrappers under
|
|
||||||
// `app/pages/[locale]/plugins/` (mirroring the `[locale]/skills/`,
|
|
||||||
// `[locale]/systems/`, etc. pattern), so it does NOT participate
|
|
||||||
// in this long-code catch-all. Both surfaces co-exist in `out/`
|
|
||||||
// because `_redirects` maps `/zh-CN/*` → `/zh/*` for the long-form
|
|
||||||
// routes; plugins lives under the short-form path only.
|
|
||||||
...skillModes.map((item) => `skills/mode/${item.slug}`),
|
|
||||||
...skillScenarios.map((item) => `skills/scenario/${item.slug}`),
|
|
||||||
...systemCategories.map((item) => `systems/category/${item.slug}`),
|
|
||||||
];
|
|
||||||
|
|
||||||
return PREFIXED_LOCALES.flatMap((locale) =>
|
return PREFIXED_LOCALES.flatMap((locale) =>
|
||||||
paths.map((path) => ({
|
paths.map((path) => ({
|
||||||
|
|
@ -62,36 +38,20 @@ const copy = getCopy(locale);
|
||||||
const pathParam = Astro.params.path ?? '';
|
const pathParam = Astro.params.path ?? '';
|
||||||
const segments = pathParam.split('/').filter(Boolean);
|
const segments = pathParam.split('/').filter(Boolean);
|
||||||
|
|
||||||
const [skills, systems, craft, templates, posts] = await Promise.all([
|
const [craft, posts] = await Promise.all([
|
||||||
getSkillRecords(),
|
|
||||||
getSystemRecords(),
|
|
||||||
getCraftRecords(),
|
getCraftRecords(),
|
||||||
getTemplateRecords(),
|
|
||||||
getCollection('blog'),
|
getCollection('blog'),
|
||||||
]);
|
]);
|
||||||
// All cross-locale subpage links resolve to canonical (English) URLs.
|
// All cross-locale subpage links resolve to canonical (English) URLs.
|
||||||
const href = (path: string) => path;
|
const href = (path: string) => path;
|
||||||
const titleSuffix = 'Open Design';
|
const titleSuffix = 'Open Design';
|
||||||
const routeRoot = segments[0] ?? '';
|
const routeRoot = segments[0] ?? '';
|
||||||
const routeSecond = segments[1] ?? '';
|
|
||||||
const routeThird = segments[2] ?? '';
|
|
||||||
|
|
||||||
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
|
||||||
const modeTags = await getSkillModeIndex();
|
const pageTitle = routeRoot === 'craft'
|
||||||
const scenarioTags = await getSkillScenarioIndex();
|
? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
|
||||||
const systemCategories = await getSystemCategoryIndex();
|
: `${copy.blog} — ${titleSuffix}`;
|
||||||
const platformTally = tally(skills.map((skill) => skill.platform).filter((item): item is string => Boolean(item)));
|
|
||||||
|
|
||||||
const pageTitle = routeRoot === 'skills'
|
|
||||||
? `${copy.skillsTitle} — ${skills.length} | ${titleSuffix}`
|
|
||||||
: routeRoot === 'systems'
|
|
||||||
? `${copy.systemsTitle} — ${systems.length} | ${titleSuffix}`
|
|
||||||
: routeRoot === 'templates'
|
|
||||||
? `${copy.templatesTitle} — ${templates.length} | ${titleSuffix}`
|
|
||||||
: routeRoot === 'craft'
|
|
||||||
? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
|
|
||||||
: `${copy.blog} — ${titleSuffix}`;
|
|
||||||
|
|
||||||
const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
|
const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
|
||||||
---
|
---
|
||||||
|
|
@ -123,61 +83,6 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{routeRoot === 'skills' && (
|
|
||||||
<>
|
|
||||||
<header class='catalog-head'>
|
|
||||||
<span class='label'>{copy.catalog} · Nº 01</span>
|
|
||||||
<h1 class='display'><em>{copy.skillsTitle}</em> — {skills.length} composable design capabilities<span class='dot'>.</span></h1>
|
|
||||||
<p class='lead'>Each skill is a folder with one <code>SKILL.md</code>. Drop it in, restart the daemon, and the picker shows it.</p>
|
|
||||||
</header>
|
|
||||||
{routeSecond === '' && (
|
|
||||||
<section class='filter-strip' aria-label='Skill filters'>
|
|
||||||
<div class='filter-group'>
|
|
||||||
<span class='filter-label'>{copy.mode}</span>
|
|
||||||
<ul>{modeTags.map((tag) => <li><a class='chip chip-link' href={href(`/skills/mode/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
|
|
||||||
</div>
|
|
||||||
<div class='filter-group'>
|
|
||||||
<span class='filter-label'>{copy.scenario}</span>
|
|
||||||
<ul>{scenarioTags.slice(0, 12).map((tag) => <li><a class='chip chip-link' href={href(`/skills/scenario/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
|
|
||||||
</div>
|
|
||||||
<div class='filter-group'>
|
|
||||||
<span class='filter-label'>{copy.platform}</span>
|
|
||||||
<ul>{platformTally.map(([key, count]) => <li><span class='chip'>{key}<span class='chip-num'>{count}</span></span></li>)}</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
<section class='catalog-grid catalog-grid-skills'>
|
|
||||||
<ol>
|
|
||||||
{skills
|
|
||||||
.filter((skill) => routeSecond === 'mode' ? skill.mode === routeThird : routeSecond === 'scenario' ? skill.scenario === routeThird : true)
|
|
||||||
.map((skill, index) => (
|
|
||||||
<li class='catalog-row'>
|
|
||||||
<a href={href(`/skills/${skill.slug}/`)}>
|
|
||||||
<span class='row-index'>{String(index + 1).padStart(2, '0')}</span>
|
|
||||||
<span class='row-body'><span class='row-name'>{skill.name}</span><span class='row-desc'>{skill.description}</span></span>
|
|
||||||
{skill.mode && <span class='meta-tag'>{skill.mode}</span>}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{routeRoot === 'systems' && (
|
|
||||||
<>
|
|
||||||
<header class='catalog-head'>
|
|
||||||
<span class='label'>{copy.catalog} · Nº 02</span>
|
|
||||||
<h1 class='display'><em>{copy.systemsTitle}</em> — {systems.length} portable visual systems<span class='dot'>.</span></h1>
|
|
||||||
<p class='lead'>Each system is a single <code>DESIGN.md</code> token spec that keeps colors, type, spacing, and components consistent.</p>
|
|
||||||
</header>
|
|
||||||
{routeSecond === '' && <section class='filter-strip'><div class='filter-group'><span class='filter-label'>{copy.category}</span><ul>{systemCategories.map((tag) => <li><a class='chip chip-link' href={href(`/systems/category/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul></div></section>}
|
|
||||||
<section class='catalog-grid systems-grid'>
|
|
||||||
<ul>{systems.filter((system) => routeSecond === 'category' ? system.category === routeThird : true).map((system) => <li class='system-card'><a href={href(`/systems/${system.slug}/`)}><span class='system-name'>{system.name}</span><p>{system.tagline}</p><span class='meta-tag'>{system.category}</span></a></li>)}</ul>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{routeRoot === 'craft' && (
|
{routeRoot === 'craft' && (
|
||||||
<>
|
<>
|
||||||
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 03</span><h1 class='display'><em>{copy.craftTitle}</em> — {craft.length} rendering principles<span class='dot'>.</span></h1><p class='lead'>Quality rules for accessibility, motion, color, type, and state coverage.</p></header>
|
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 03</span><h1 class='display'><em>{copy.craftTitle}</em> — {craft.length} rendering principles<span class='dot'>.</span></h1><p class='lead'>Quality rules for accessibility, motion, color, type, and state coverage.</p></header>
|
||||||
|
|
@ -185,11 +90,4 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{routeRoot === 'templates' && (
|
|
||||||
<>
|
|
||||||
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 04</span><h1 class='display'><em>{copy.templatesTitle}</em> — {templates.length} ready-to-fork artifacts<span class='dot'>.</span></h1><p class='lead'>Pre-wired artifact bundles with examples, visual language, and agent instructions.</p></header>
|
|
||||||
<section class='template-grid'><ul>{templates.map((template, index) => <li class='template-card'><a href={href(template.detailHref)}>{template.previewUrl && <span class='template-thumb'><LazyImg src={template.previewUrl} alt='' loading={index < 4 ? 'eager' : 'precise'} /></span>}<span class='template-name'>{template.name}</span><p class='template-summary'>{template.summary}</p></a></li>)}</ul></section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
import SkillPage, {
|
|
||||||
getStaticPaths as getSkillStaticPaths,
|
|
||||||
} from '../../skills/[slug]/index.astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const basePaths = await getSkillStaticPaths();
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
|
||||||
(locale) =>
|
|
||||||
basePaths.map((path) => ({
|
|
||||||
params: { ...path.params, locale: locale.code },
|
|
||||||
props: path.props,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<SkillPage {...Astro.props} />
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
import SkillsPage from '../../skills/index.astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
|
||||||
|
|
||||||
export function getStaticPaths() {
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
|
|
||||||
(locale) => ({ params: { locale: locale.code } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<SkillsPage />
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
import SkillModePage, {
|
|
||||||
getStaticPaths as getSkillModeStaticPaths,
|
|
||||||
} from '../../../skills/mode/[mode].astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const basePaths = await getSkillModeStaticPaths();
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
|
||||||
(locale) =>
|
|
||||||
basePaths.map((path) => ({
|
|
||||||
params: { ...path.params, locale: locale.code },
|
|
||||||
props: path.props,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<SkillModePage {...Astro.props} />
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
import SkillScenarioPage, {
|
|
||||||
getStaticPaths as getSkillScenarioStaticPaths,
|
|
||||||
} from '../../../skills/scenario/[scenario].astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const basePaths = await getSkillScenarioStaticPaths();
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
|
||||||
(locale) =>
|
|
||||||
basePaths.map((path) => ({
|
|
||||||
params: { ...path.params, locale: locale.code },
|
|
||||||
props: path.props,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<SkillScenarioPage {...Astro.props} />
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
import SystemPage, {
|
|
||||||
getStaticPaths as getSystemStaticPaths,
|
|
||||||
} from '../../systems/[slug].astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const basePaths = await getSystemStaticPaths();
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
|
||||||
(locale) =>
|
|
||||||
basePaths.map((path) => ({
|
|
||||||
params: { ...path.params, locale: locale.code },
|
|
||||||
props: path.props,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<SystemPage {...Astro.props} />
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
import SystemCategoryPage, {
|
|
||||||
getStaticPaths as getSystemCategoryStaticPaths,
|
|
||||||
} from '../../../systems/category/[category].astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const basePaths = await getSystemCategoryStaticPaths();
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
|
||||||
(locale) =>
|
|
||||||
basePaths.map((path) => ({
|
|
||||||
params: { ...path.params, locale: locale.code },
|
|
||||||
props: path.props,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<SystemCategoryPage {...Astro.props} />
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
import SystemsPage from '../../systems/index.astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
|
||||||
|
|
||||||
export function getStaticPaths() {
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
|
|
||||||
(locale) => ({ params: { locale: locale.code } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<SystemsPage />
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
import TemplatePage, {
|
|
||||||
getStaticPaths as getTemplateStaticPaths,
|
|
||||||
} from '../../templates/[slug]/index.astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const basePaths = await getTemplateStaticPaths();
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
|
||||||
(locale) =>
|
|
||||||
basePaths.map((path) => ({
|
|
||||||
params: { ...path.params, locale: locale.code },
|
|
||||||
props: path.props,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<TemplatePage {...Astro.props} />
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
import TemplatesPage from '../../templates/index.astro';
|
|
||||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
|
||||||
|
|
||||||
export function getStaticPaths() {
|
|
||||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
|
|
||||||
(locale) => ({ params: { locale: locale.code } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<TemplatesPage />
|
|
||||||
|
|
@ -207,8 +207,8 @@ const jsonLd = [
|
||||||
<h2>{page.nextTitle}</h2>
|
<h2>{page.nextTitle}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
||||||
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||||
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
||||||
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ const bottomCta =
|
||||||
? {
|
? {
|
||||||
title: ui.blog.cta.skillsTitle,
|
title: ui.blog.cta.skillsTitle,
|
||||||
body: ui.blog.cta.skillsBody,
|
body: ui.blog.cta.skillsBody,
|
||||||
href: '/skills/',
|
href: '/plugins/skills/',
|
||||||
label: ui.blog.cta.skillsLabel,
|
label: ui.blog.cta.skillsLabel,
|
||||||
external: false,
|
external: false,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1058,7 +1058,7 @@ pnpm -F @html-anything/next dev
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a class="ha-btn" href={href('/')}>{copy.visitOpenDesign}</a>
|
<a class="ha-btn" href={href('/')}>{copy.visitOpenDesign}</a>
|
||||||
<a class="ha-btn" href={href('/skills/')} rel="noopener">{copy.browseSkills}</a>
|
<a class="ha-btn" href={href('/plugins/skills/')} rel="noopener">{copy.browseSkills}</a>
|
||||||
<a class="ha-btn" href={HA_URL} rel="noopener">{copy.githubLink}</a>
|
<a class="ha-btn" href={HA_URL} rel="noopener">{copy.githubLink}</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,9 @@ const sources = [
|
||||||
{ ...page.sources[4], href: DISCORD },
|
{ ...page.sources[4], href: DISCORD },
|
||||||
{ ...page.sources[5], href: DOCS },
|
{ ...page.sources[5], href: DOCS },
|
||||||
{ ...page.sources[6], href: REPO_LICENSE },
|
{ ...page.sources[6], href: REPO_LICENSE },
|
||||||
{ ...page.sources[7], href: href('/skills/') },
|
{ ...page.sources[7], href: href('/plugins/skills/') },
|
||||||
{ ...page.sources[8], href: href('/systems/') },
|
{ ...page.sources[8], href: href('/plugins/systems/') },
|
||||||
{ ...page.sources[9], href: href('/templates/') },
|
{ ...page.sources[9], href: href('/plugins/templates/') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const jsonLd = [
|
const jsonLd = [
|
||||||
|
|
@ -140,8 +140,8 @@ const jsonLd = [
|
||||||
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
||||||
<li><a class="inline-link" href={href('/agents/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
<li><a class="inline-link" href={href('/agents/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||||
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
||||||
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
||||||
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
|
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,7 @@
|
||||||
*/
|
*/
|
||||||
import Layout from '../../../_components/sub-page-layout.astro';
|
import Layout from '../../../_components/sub-page-layout.astro';
|
||||||
import SystemCard from '../../../_components/system-card.astro';
|
import SystemCard from '../../../_components/system-card.astro';
|
||||||
import {
|
import { getSystemRecords } from '../../../_lib/catalog';
|
||||||
getSystemRecords,
|
|
||||||
getSystemCategoryIndex,
|
|
||||||
} from '../../../_lib/catalog';
|
|
||||||
import { getPluginsCopy } from '../../../_lib/plugins-i18n';
|
import { getPluginsCopy } from '../../../_lib/plugins-i18n';
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
||||||
|
|
||||||
|
|
@ -24,7 +21,6 @@ const ui = getLandingUiCopy(locale);
|
||||||
const pcopy = getPluginsCopy(locale);
|
const pcopy = getPluginsCopy(locale);
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
const href = (path: string) => localizedHref(path, locale);
|
||||||
const systems = await getSystemRecords(locale);
|
const systems = await getSystemRecords(locale);
|
||||||
const categoryTags = await getSystemCategoryIndex(locale);
|
|
||||||
|
|
||||||
const title = `${pcopy.tileSystems} · ${systems.length} · Open Design`;
|
const title = `${pcopy.tileSystems} · ${systems.length} · Open Design`;
|
||||||
const description = pcopy.systemsLead;
|
const description = pcopy.systemsLead;
|
||||||
|
|
@ -54,21 +50,6 @@ const jsonLd = {
|
||||||
<p class="lead">{pcopy.systemsLead}</p>
|
<p class="lead">{pcopy.systemsLead}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">{ui.catalog.systems.category}</span>
|
|
||||||
<ul>
|
|
||||||
{categoryTags.map((tag) => (
|
|
||||||
<li>
|
|
||||||
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
|
|
||||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
||||||
<ul>
|
<ul>
|
||||||
{systems.map((s) => <SystemCard system={s} />)}
|
{systems.map((s) => <SystemCard system={s} />)}
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,8 @@ const jsonLd = [
|
||||||
<section class="info-section" id="next">
|
<section class="info-section" id="next">
|
||||||
<h2>{page.nextTitle}</h2>
|
<h2>{page.nextTitle}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
||||||
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||||
<li><a class="inline-link" href={href('/compare/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
<li><a class="inline-link" href={href('/compare/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
||||||
<li><a class="inline-link" href={REPO_RELEASES} target="_blank" rel="noreferrer noopener">{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
<li><a class="inline-link" href={REPO_RELEASES} target="_blank" rel="noreferrer noopener">{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,472 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* /skills/<slug>/ — a detail page per skill.
|
|
||||||
*
|
|
||||||
* Two flavours render slightly differently:
|
|
||||||
* - `template` skills get a click-to-expand iframe of their
|
|
||||||
* `example.html` demo and stay deliberately brief — the demo is the
|
|
||||||
* content, the README is one click away on GitHub.
|
|
||||||
* - `instruction` skills (no runnable demo) instead render the full
|
|
||||||
* SKILL.md body inline, so the page reads like a brief: what the
|
|
||||||
* skill does, when it triggers, how to use it. Otherwise the page
|
|
||||||
* would be a one-line description and a row of CTAs.
|
|
||||||
*/
|
|
||||||
import { getEntry, render } from 'astro:content';
|
|
||||||
import Layout from '../../../_components/sub-page-layout.astro';
|
|
||||||
import LazyImg from '../../../_components/lazy-img.astro';
|
|
||||||
import { getSkillRecords, type SkillRecord } from '../../../_lib/catalog';
|
|
||||||
import {
|
|
||||||
getLandingUiCopy,
|
|
||||||
localeFromPath,
|
|
||||||
localizedHref,
|
|
||||||
type LandingLocaleCode,
|
|
||||||
} from '../../../i18n';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Localized share-copy template, keyed by landing locale. The brand
|
|
||||||
* keyword "open-source Claude Design alternative" stays in English
|
|
||||||
* because that's the canonical search query Google associates with
|
|
||||||
* the domain — translating it would split the entity claim. The
|
|
||||||
* surrounding sentence ("I'm using X from @opendesignai") translates
|
|
||||||
* per locale so the message reads as one coherent voice instead of
|
|
||||||
* mixing two scripts in a single share post.
|
|
||||||
*
|
|
||||||
* `{name}` and `{description}` are interpolated at render time.
|
|
||||||
* `{url}` is replaced with the canonical detail-page URL.
|
|
||||||
*/
|
|
||||||
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
|
|
||||||
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
|
|
||||||
en: ({ name, description, url }) => `🎨 Just discovered ${name} on @opendesignai — the open-source Claude Design alternative.
|
|
||||||
✨ Local-first · BYOK · your agent does the design.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
zh: ({ name, description, url }) => `🎨 安利一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
|
|
||||||
✨ 本地优先 · 自带模型 · 让你自己的 agent 做设计。
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
'zh-tw': ({ name, description, url }) => `🎨 推薦一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
|
|
||||||
✨ 本地優先 · 自帶模型 · 讓你自己的 agent 做設計。
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ja: ({ name, description, url }) => `🎨 @opendesignai で ${name} を発見 —— オープンソースの Claude Design 代替。
|
|
||||||
✨ ローカル優先 · BYOK · あなたのエージェントが設計する。
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ko: ({ name, description, url }) => `🎨 @opendesignai에서 ${name} 발견 —— 오픈 소스 Claude Design 대안.
|
|
||||||
✨ 로컬 우선 · BYOK · 에이전트가 디자인합니다.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
de: ({ name, description, url }) => `🎨 Gerade entdeckt: ${name} auf @opendesignai — die Open-Source-Alternative zu Claude Design.
|
|
||||||
✨ Local-first · BYOK · dein Agent designt.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
fr: ({ name, description, url }) => `🎨 Découvert : ${name} sur @opendesignai — l'alternative open-source à Claude Design.
|
|
||||||
✨ Local-first · BYOK · votre agent fait le design.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ru: ({ name, description, url }) => `🎨 Нашёл ${name} на @opendesignai — open-source альтернативу Claude Design.
|
|
||||||
✨ Локально · BYOK · агент сам делает дизайн.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
es: ({ name, description, url }) => `🎨 Acabo de descubrir ${name} en @opendesignai — la alternativa open-source a Claude Design.
|
|
||||||
✨ Local-first · BYOK · tu agente diseña.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
'pt-br': ({ name, description, url }) => `🎨 Acabei de descobrir ${name} no @opendesignai — a alternativa open-source ao Claude Design.
|
|
||||||
✨ Local-first · BYOK · seu agente faz o design.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
it: ({ name, description, url }) => `🎨 Ho appena scoperto ${name} su @opendesignai — l'alternativa open-source a Claude Design.
|
|
||||||
✨ Local-first · BYOK · il tuo agente progetta.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
vi: ({ name, description, url }) => `🎨 Vừa khám phá ${name} trên @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
|
|
||||||
✨ Ưu tiên local · BYOK · agent của bạn thiết kế.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
pl: ({ name, description, url }) => `🎨 Właśnie odkryłem ${name} na @opendesignai — open-source'ową alternatywę dla Claude Design.
|
|
||||||
✨ Local-first · BYOK · twój agent projektuje.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
id: ({ name, description, url }) => `🎨 Baru nemu ${name} di @opendesignai — alternatif open-source untuk Claude Design.
|
|
||||||
✨ Local-first · BYOK · agent kamu yang nge-desain.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
nl: ({ name, description, url }) => `🎨 Net ontdekt: ${name} op @opendesignai — het open-source alternatief voor Claude Design.
|
|
||||||
✨ Local-first · BYOK · jouw agent ontwerpt.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ar: ({ name, description, url }) => `🎨 اكتشفت للتو ${name} على @opendesignai — البديل مفتوح المصدر لـ Claude Design.
|
|
||||||
✨ محلي أولًا · BYOK · وكيلك يصمّم.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
tr: ({ name, description, url }) => `🎨 Yeni keşfettim: ${name} (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
|
|
||||||
✨ Local-first · BYOK · ajanın tasarlıyor.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
uk: ({ name, description, url }) => `🎨 Щойно знайшов ${name} на @opendesignai — open-source альтернативу Claude Design.
|
|
||||||
✨ Local-first · BYOK · ваш агент робить дизайн.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const skills = await getSkillRecords();
|
|
||||||
return skills.map((skill) => ({
|
|
||||||
params: { slug: skill.slug },
|
|
||||||
props: { skill, all: skills },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
skill: SkillRecord;
|
|
||||||
all: ReadonlyArray<SkillRecord>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { skill: routeSkill, all: routeAll } = Astro.props as Props;
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const all = locale === 'en' ? routeAll : await getSkillRecords(locale);
|
|
||||||
const skill = all.find((item) => item.slug === routeSkill.slug) ?? routeSkill;
|
|
||||||
|
|
||||||
const title = ui.catalog.skills.detailTitle(skill.name);
|
|
||||||
const description = skill.description.length > 0
|
|
||||||
? skill.description
|
|
||||||
: ui.catalog.skills.detailFallbackDescription(skill.name);
|
|
||||||
|
|
||||||
const skillUrl = `https://open-design.ai/skills/${skill.slug}/`;
|
|
||||||
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
|
|
||||||
name: skill.name,
|
|
||||||
description,
|
|
||||||
url: skillUrl,
|
|
||||||
});
|
|
||||||
// Share-dialog UI strings localized inline. Keeping them next to the
|
|
||||||
// page that uses them avoids growing the global UI bundle for what's
|
|
||||||
// effectively four short labels per locale.
|
|
||||||
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
|
|
||||||
en: { title: 'Share this skill', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
|
|
||||||
zh: { title: '分享这个 skill', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
|
|
||||||
'zh-tw': { title: '分享這個 skill', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
|
|
||||||
ja: { title: 'この skill を共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
|
|
||||||
ko: { title: '이 skill 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
|
|
||||||
de: { title: 'Diesen Skill teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
|
|
||||||
fr: { title: 'Partager ce skill', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
|
|
||||||
ru: { title: 'Поделиться скиллом', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
|
|
||||||
es: { title: 'Compartir este skill', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
|
|
||||||
'pt-br': { title: 'Compartilhar skill', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
|
|
||||||
it: { title: 'Condividi lo skill', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
|
|
||||||
vi: { title: 'Chia sẻ skill', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
|
|
||||||
pl: { title: 'Udostępnij ten skill', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
|
|
||||||
id: { title: 'Bagikan skill ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
|
|
||||||
nl: { title: 'Deel deze skill', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
|
|
||||||
ar: { title: 'شارك هذه المهارة', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
|
|
||||||
tr: { title: 'Bu skilli paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
|
|
||||||
uk: { title: 'Поділитись скілом', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
|
|
||||||
};
|
|
||||||
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
|
|
||||||
|
|
||||||
const related = all
|
|
||||||
.filter((s) => s.slug !== skill.slug)
|
|
||||||
.filter((s) => s.mode === skill.mode || s.scenario === skill.scenario)
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Instruction skills don't have a runnable demo to iframe — to avoid
|
|
||||||
* a near-empty detail page, render the SKILL.md prose inline so the
|
|
||||||
* page reads like a brief. Template skills keep the page deliberately
|
|
||||||
* brief because their demo is the content; their full SKILL.md is one
|
|
||||||
* "Find on GitHub" click away.
|
|
||||||
*
|
|
||||||
* Astro 6 exposes the markdown pipeline through a top-level
|
|
||||||
* `render(entry)` helper rather than the legacy `entry.render()`
|
|
||||||
* method. The output (heading anchors, smart-typography, GFM
|
|
||||||
* tables) styles cleanly with the existing `.detail-md` rules.
|
|
||||||
*/
|
|
||||||
const skillEntry =
|
|
||||||
skill.kind === 'instruction' ? await getEntry('skills', `${skill.slug}/SKILL`) : null;
|
|
||||||
const SkillBody = skillEntry ? (await render(skillEntry)).Content : null;
|
|
||||||
|
|
||||||
const jsonLd = [
|
|
||||||
{
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'BreadcrumbList',
|
|
||||||
itemListElement: [
|
|
||||||
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
|
|
||||||
{ '@type': 'ListItem', position: 2, name: ui.catalog.skills.detailLabel, item: new URL('/skills/', Astro.site).toString() },
|
|
||||||
{ '@type': 'ListItem', position: 3, name: skill.name, item: new URL(`/skills/${skill.slug}/`, Astro.site).toString() },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'SoftwareSourceCode',
|
|
||||||
name: skill.name,
|
|
||||||
description,
|
|
||||||
codeRepository: skill.source,
|
|
||||||
programmingLanguage: 'Markdown',
|
|
||||||
keywords: skill.triggers.join(', '),
|
|
||||||
license: 'https://www.apache.org/licenses/LICENSE-2.0',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
|
||||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
|
||||||
<a href={href('/')}>Open Design</a>
|
|
||||||
<span>/</span>
|
|
||||||
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span aria-current="page">{skill.name}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<article class="detail">
|
|
||||||
<header class="detail-head">
|
|
||||||
<span class="label">
|
|
||||||
{ui.catalog.skills.detailLabel}
|
|
||||||
{typeof skill.featured === 'number' && (
|
|
||||||
<span class="ix">{ui.catalog.skills.featuredNumber(String(skill.featured).padStart(2, '0'))}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<h1 class="display">{skill.name}<span class="dot">.</span></h1>
|
|
||||||
<p class="lead">{description}</p>
|
|
||||||
<div class="detail-actions">
|
|
||||||
{/*
|
|
||||||
Two primary CTAs. "Use this skill" v1 sends users to the OD
|
|
||||||
desktop release page — install the app first, then run the
|
|
||||||
skill. Routing here rather than to /quickstart/ keeps the
|
|
||||||
flow concrete (download a binary now) instead of asking
|
|
||||||
users to read an install doc. Once the desktop client
|
|
||||||
exposes a registered URL scheme, this anchor flips to a
|
|
||||||
JS-driven `od://skill/<slug>` try + fallback without
|
|
||||||
changing the page surface.
|
|
||||||
*/}
|
|
||||||
<a
|
|
||||||
class="btn btn-primary"
|
|
||||||
href="https://github.com/nexu-io/open-design/releases"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Use this skill →
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="btn btn-ghost"
|
|
||||||
href={skill.source}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Find on GitHub →
|
|
||||||
</a>
|
|
||||||
{skill.upstream && (
|
|
||||||
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
|
|
||||||
{ui.catalog.skills.upstream}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost detail-share-trigger"
|
|
||||||
data-share-open={`skill:${skill.slug}`}
|
|
||||||
>
|
|
||||||
{shareUi.openLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{skill.kind === 'template' && skill.previewUrl && (
|
|
||||||
<figure class="detail-preview">
|
|
||||||
{/*
|
|
||||||
Click-to-expand interactive preview. Only template-kind skills
|
|
||||||
ship a runnable example.html, so this block is gated on kind
|
|
||||||
rather than just `previewUrl` — instruction skills now have a
|
|
||||||
synthesized cover thumbnail too, but no iframe target. The
|
|
||||||
thumb is the summary of a `<details>` element: clicking opens
|
|
||||||
the live iframe, replacing the thumb with the canonical
|
|
||||||
`<slug>/example.html` rendered inside a sandboxed frame.
|
|
||||||
*/}
|
|
||||||
<details class="detail-preview-live">
|
|
||||||
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${skill.name}`}>
|
|
||||||
<LazyImg
|
|
||||||
src={skill.previewUrl}
|
|
||||||
alt={`${skill.name} example output`}
|
|
||||||
loading="priority"
|
|
||||||
/>
|
|
||||||
<span class="detail-preview-thumb-overlay" aria-hidden="true">
|
|
||||||
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div class="detail-preview-frame-wrap">
|
|
||||||
<iframe
|
|
||||||
src={`/skills/${skill.slug}/example.html`}
|
|
||||||
title={`${skill.name} interactive preview`}
|
|
||||||
loading="lazy"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
class="detail-preview-frame"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
class="detail-preview-popout"
|
|
||||||
href={`/skills/${skill.slug}/example.html`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
aria-label="Open preview in new tab"
|
|
||||||
>
|
|
||||||
Open in new tab ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<figcaption>
|
|
||||||
{ui.catalog.skills.previewCaption(skill.slug)}
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/*
|
|
||||||
Share modal — opens a `<dialog>` containing the canonical share
|
|
||||||
copy (with the brand keyword "open-source Claude Design
|
|
||||||
alternative" baked in), a one-click "Copy" button, and a row of
|
|
||||||
platform jump buttons. Each platform button just opens the
|
|
||||||
vendor's compose URL — the user pastes the already-copied text.
|
|
||||||
This works around a real cross-platform pain point: LinkedIn /
|
|
||||||
Facebook ignore pre-fill `text` params, X has length limits that
|
|
||||||
truncate Chinese content unpredictably, and Reddit's title param
|
|
||||||
survives but title-only is a weak signal. Copy-then-paste is
|
|
||||||
uniformly reliable.
|
|
||||||
|
|
||||||
The trigger sits inside `.detail-actions` instead of as a
|
|
||||||
separate row below `.detail-meta` so it has visual weight equal
|
|
||||||
to the primary CTAs. Joey called this out specifically.
|
|
||||||
*/}
|
|
||||||
<dialog
|
|
||||||
class="detail-share-dialog"
|
|
||||||
data-share-dialog={`skill:${skill.slug}`}
|
|
||||||
>
|
|
||||||
<form method="dialog" class="detail-share-dialog-form">
|
|
||||||
<header class="detail-share-dialog-head">
|
|
||||||
<h2>{shareUi.title}</h2>
|
|
||||||
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
|
|
||||||
</header>
|
|
||||||
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
|
|
||||||
<textarea
|
|
||||||
class="detail-share-dialog-text"
|
|
||||||
readonly
|
|
||||||
rows="6"
|
|
||||||
data-share-text
|
|
||||||
>{shareCopy}</textarea>
|
|
||||||
<div class="detail-share-dialog-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary detail-share-dialog-copy"
|
|
||||||
data-share-copy
|
|
||||||
>
|
|
||||||
{shareUi.copyText}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost detail-share-dialog-copy-link"
|
|
||||||
data-copy-link={skillUrl}
|
|
||||||
>
|
|
||||||
{shareUi.copyLink}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/*
|
|
||||||
Platform jump buttons — official brand logos rendered as
|
|
||||||
inline SVG (no third-party icon font, no client JS). Each
|
|
||||||
opens the vendor's compose surface in a new tab; the user
|
|
||||||
pastes the already-copied text. Email channel was dropped
|
|
||||||
per Joey's revision; the four channels here cover the
|
|
||||||
highest-value SEO + virality surfaces.
|
|
||||||
*/}
|
|
||||||
<div class="detail-share-dialog-platforms">
|
|
||||||
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
|
|
||||||
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
|
|
||||||
<span class="sr-only">X</span>
|
|
||||||
</a>
|
|
||||||
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
|
||||||
<span class="sr-only">LinkedIn</span>
|
|
||||||
</a>
|
|
||||||
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
|
|
||||||
<span class="sr-only">Reddit</span>
|
|
||||||
</a>
|
|
||||||
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
|
||||||
<span class="sr-only">Facebook</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dl class="detail-meta">
|
|
||||||
{skill.mode && (
|
|
||||||
<Fragment>
|
|
||||||
<dt>{ui.catalog.skills.mode}</dt>
|
|
||||||
<dd>{skill.modeLabel ?? skill.mode}</dd>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
{skill.scenario && (
|
|
||||||
<Fragment>
|
|
||||||
<dt>{ui.catalog.skills.scenario}</dt>
|
|
||||||
<dd>{skill.scenarioLabel ?? skill.scenario}</dd>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
{skill.platform && (
|
|
||||||
<Fragment>
|
|
||||||
<dt>{ui.catalog.skills.platform}</dt>
|
|
||||||
<dd>{skill.platformLabel ?? skill.platform}</dd>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
{skill.category && (
|
|
||||||
<Fragment>
|
|
||||||
<dt>{ui.catalog.systems.category}</dt>
|
|
||||||
<dd>{skill.categoryLabel ?? skill.category}</dd>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{skill.triggers.length > 0 && (
|
|
||||||
<section class="detail-block">
|
|
||||||
<h2>{ui.catalog.skills.triggers}</h2>
|
|
||||||
<p class="block-lead">
|
|
||||||
{ui.catalog.skills.triggersLead}
|
|
||||||
</p>
|
|
||||||
<ul class="trigger-list">
|
|
||||||
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{skill.examplePrompt && (
|
|
||||||
<section class="detail-block">
|
|
||||||
<h2>{ui.catalog.skills.examplePrompt}</h2>
|
|
||||||
<pre class="example-prompt">{skill.examplePrompt}</pre>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{SkillBody && (
|
|
||||||
<section class="detail-block detail-md">
|
|
||||||
<h2>About this skill</h2>
|
|
||||||
<SkillBody />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{related.length > 0 && (
|
|
||||||
<section class="detail-block">
|
|
||||||
<h2>{ui.catalog.skills.related}</h2>
|
|
||||||
<ul class="related-grid">
|
|
||||||
{related.map((r) => (
|
|
||||||
<li>
|
|
||||||
<a href={href(`/skills/${r.slug}/`)}>
|
|
||||||
<span class="related-name">{r.name}</span>
|
|
||||||
<span class="related-desc">{r.description}</span>
|
|
||||||
<span class="related-meta">
|
|
||||||
{r.modeLabel && <span class="meta-tag">{r.modeLabel}</span>}
|
|
||||||
{r.scenarioLabel && <span class="meta-tag muted">{r.scenarioLabel}</span>}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* /skills/ — index of every shippable skill in the repo.
|
|
||||||
*
|
|
||||||
* Pulls live data from `skills/<slug>/SKILL.md` via Astro Content
|
|
||||||
* Collections so adding a skill anywhere in the monorepo
|
|
||||||
* automatically surfaces here on the next build.
|
|
||||||
*/
|
|
||||||
import Layout from '../../_components/sub-page-layout.astro';
|
|
||||||
import LazyImg from '../../_components/lazy-img.astro';
|
|
||||||
import SkillRow from '../../_components/skill-row.astro';
|
|
||||||
import {
|
|
||||||
getSkillRecords,
|
|
||||||
getSkillModeIndex,
|
|
||||||
getSkillScenarioIndex,
|
|
||||||
tally,
|
|
||||||
} from '../../_lib/catalog';
|
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
|
||||||
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const skills = await getSkillRecords(locale);
|
|
||||||
|
|
||||||
const modeTags = await getSkillModeIndex(locale);
|
|
||||||
const scenarioTags = await getSkillScenarioIndex(locale);
|
|
||||||
const platformTally = tally(
|
|
||||||
skills.map((s) => s.platformLabel).filter((p): p is string => Boolean(p)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const featured = skills.filter((s) => typeof s.featured === 'number').slice(0, 6);
|
|
||||||
|
|
||||||
const title = ui.catalog.skills.title(skills.length);
|
|
||||||
const description = ui.catalog.skills.description;
|
|
||||||
|
|
||||||
const jsonLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'CollectionPage',
|
|
||||||
name: title,
|
|
||||||
description,
|
|
||||||
url: new URL('/skills/', Astro.site).toString(),
|
|
||||||
isPartOf: {
|
|
||||||
'@type': 'WebSite',
|
|
||||||
name: 'Open Design',
|
|
||||||
url: Astro.site?.toString(),
|
|
||||||
},
|
|
||||||
numberOfItems: skills.length,
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
|
||||||
<header class="catalog-head">
|
|
||||||
<span class="label">{ui.catalog.skills.label}</span>
|
|
||||||
<h1 class="display">
|
|
||||||
{ui.catalog.skills.heading(skills.length)}
|
|
||||||
</h1>
|
|
||||||
<p class="lead">
|
|
||||||
{ui.catalog.skills.lead}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="filter-strip" aria-label={ui.catalog.skills.allAria}>
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">{ui.catalog.skills.mode}</span>
|
|
||||||
<ul>
|
|
||||||
{modeTags.map((tag) => (
|
|
||||||
<li>
|
|
||||||
<a class="chip chip-link" href={href(`/skills/mode/${tag.slug}/`)}>
|
|
||||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">{ui.catalog.skills.scenario}</span>
|
|
||||||
<ul>
|
|
||||||
{scenarioTags.slice(0, 12).map((tag) => (
|
|
||||||
<li>
|
|
||||||
<a class="chip chip-link" href={href(`/skills/scenario/${tag.slug}/`)}>
|
|
||||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{platformTally.length > 0 && (
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">{ui.catalog.skills.platform}</span>
|
|
||||||
<ul>
|
|
||||||
{platformTally.map(([key, count]) => (
|
|
||||||
<li>
|
|
||||||
<span class="chip">
|
|
||||||
{key}<span class="chip-num">{count}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{featured.length > 0 && (
|
|
||||||
<section class="featured-strip" aria-labelledby="featured-skills">
|
|
||||||
<h2 id="featured-skills" class="strip-title">{ui.catalog.skills.featured}</h2>
|
|
||||||
<ul class="featured-grid">
|
|
||||||
{featured.map((s, i) => (
|
|
||||||
<li class="featured-card">
|
|
||||||
<a href={href(`/skills/${s.slug}/`)}>
|
|
||||||
{s.previewUrl ? (
|
|
||||||
<span class="featured-thumb">
|
|
||||||
<LazyImg src={s.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span class="featured-thumb featured-thumb-empty" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
<span class="featured-num">Nº {String(s.featured).padStart(2, '0')}</span>
|
|
||||||
<span class="featured-name">{s.name}</span>
|
|
||||||
<p>{s.description}</p>
|
|
||||||
{s.modeLabel && <span class="meta-tag">{s.modeLabel}</span>}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
|
|
||||||
<ol>
|
|
||||||
{skills.map((s, idx) => <SkillRow skill={s} index={idx} />)}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* /skills/mode/<slug>/ — every skill that emits a given artifact mode
|
|
||||||
* (deck, prototype, template, image, video, audio, design-system, utility).
|
|
||||||
*
|
|
||||||
* One static page per distinct `od.mode` value. Mode is the strongest
|
|
||||||
* mental-model facet ("I want a deck-builder") so this is the primary
|
|
||||||
* faceted view; scenario/category live alongside.
|
|
||||||
*/
|
|
||||||
import Layout from '../../../_components/sub-page-layout.astro';
|
|
||||||
import SkillRow from '../../../_components/skill-row.astro';
|
|
||||||
import {
|
|
||||||
getSkillModeIndex,
|
|
||||||
getSkillsForMode,
|
|
||||||
type TagDescriptor,
|
|
||||||
} from '../../../_lib/catalog';
|
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const tags = await getSkillModeIndex();
|
|
||||||
return tags.map((tag) => ({
|
|
||||||
params: { mode: tag.slug },
|
|
||||||
props: { tag },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tag: TagDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tag } = Astro.props as Props;
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const { records, label } = await getSkillsForMode(tag.slug, locale);
|
|
||||||
const heading = label ?? tag.label;
|
|
||||||
|
|
||||||
const title = ui.catalog.skills.filterTitle(heading, records.length);
|
|
||||||
const description = ui.catalog.skills.modeDescription(heading, records.length);
|
|
||||||
|
|
||||||
const url = new URL(`/skills/mode/${tag.slug}/`, Astro.site).toString();
|
|
||||||
const jsonLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'CollectionPage',
|
|
||||||
name: title,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
numberOfItems: records.length,
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
|
||||||
<header class="catalog-head">
|
|
||||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
|
||||||
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<span>{ui.catalog.skills.mode}</span>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<span class="crumb-active">{heading}</span>
|
|
||||||
</nav>
|
|
||||||
<span class="label">{ui.catalog.skills.label}</span>
|
|
||||||
<h1 class="display">
|
|
||||||
{ui.catalog.skills.modeHeading(heading, records.length)}
|
|
||||||
</h1>
|
|
||||||
<p class="lead">
|
|
||||||
{ui.catalog.skills.modeLead(label ?? tag.label)}
|
|
||||||
</p>
|
|
||||||
<p class="filter-clear">
|
|
||||||
<a href={href('/skills/')}>{ui.catalog.skills.allSkills(tag.count)}</a>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
|
|
||||||
<ol>
|
|
||||||
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* /skills/scenario/<slug>/ — every skill targeting a given use-case
|
|
||||||
* scenario (marketing, engineering, design, research, ...).
|
|
||||||
*
|
|
||||||
* Mirrors the mode page but facets on `od.scenario`. One page per
|
|
||||||
* distinct scenario value found across all SKILL.md files.
|
|
||||||
*/
|
|
||||||
import Layout from '../../../_components/sub-page-layout.astro';
|
|
||||||
import SkillRow from '../../../_components/skill-row.astro';
|
|
||||||
import {
|
|
||||||
getSkillScenarioIndex,
|
|
||||||
getSkillsForScenario,
|
|
||||||
type TagDescriptor,
|
|
||||||
} from '../../../_lib/catalog';
|
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const tags = await getSkillScenarioIndex();
|
|
||||||
return tags.map((tag) => ({
|
|
||||||
params: { scenario: tag.slug },
|
|
||||||
props: { tag },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tag: TagDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tag } = Astro.props as Props;
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const { records, label } = await getSkillsForScenario(tag.slug, locale);
|
|
||||||
const heading = label ?? tag.label;
|
|
||||||
|
|
||||||
const title = ui.catalog.skills.filterTitle(heading, records.length);
|
|
||||||
const description = ui.catalog.skills.scenarioDescription(heading, records.length);
|
|
||||||
|
|
||||||
const url = new URL(`/skills/scenario/${tag.slug}/`, Astro.site).toString();
|
|
||||||
const jsonLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'CollectionPage',
|
|
||||||
name: title,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
numberOfItems: records.length,
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
|
||||||
<header class="catalog-head">
|
|
||||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
|
||||||
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<span>{ui.catalog.skills.scenario}</span>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<span class="crumb-active">{heading}</span>
|
|
||||||
</nav>
|
|
||||||
<span class="label">{ui.catalog.skills.label}</span>
|
|
||||||
<h1 class="display">
|
|
||||||
{ui.catalog.skills.scenarioHeading(heading, records.length)}
|
|
||||||
</h1>
|
|
||||||
<p class="lead">
|
|
||||||
{ui.catalog.skills.scenarioLead(label ?? tag.label)}
|
|
||||||
</p>
|
|
||||||
<p class="filter-clear">
|
|
||||||
<a href={href('/skills/')}>{ui.catalog.skills.allSkills()}</a>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
|
|
||||||
<ol>
|
|
||||||
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
---
|
|
||||||
import Layout from '../../_components/sub-page-layout.astro';
|
|
||||||
import { getSystemRecords, type SystemRecord } from '../../_lib/catalog';
|
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const systems = await getSystemRecords();
|
|
||||||
return systems.map((system) => ({
|
|
||||||
params: { slug: system.slug },
|
|
||||||
props: { system, all: systems },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
system: SystemRecord;
|
|
||||||
all: ReadonlyArray<SystemRecord>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { system: routeSystem, all: routeAll } = Astro.props as Props;
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const all = locale === 'en' ? routeAll : await getSystemRecords(locale);
|
|
||||||
const system = all.find((item) => item.slug === routeSystem.slug) ?? routeSystem;
|
|
||||||
|
|
||||||
const title = ui.catalog.systems.detailTitle(system.name);
|
|
||||||
const description = system.tagline
|
|
||||||
? `${system.name} (${system.categoryLabel}) — ${system.tagline}`
|
|
||||||
: ui.catalog.systems.detailFallbackDescription(system.name, system.categoryLabel);
|
|
||||||
|
|
||||||
const related = all
|
|
||||||
.filter((s) => s.slug !== system.slug && s.category === system.category)
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
const jsonLd = [
|
|
||||||
{
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'BreadcrumbList',
|
|
||||||
itemListElement: [
|
|
||||||
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
|
|
||||||
{ '@type': 'ListItem', position: 2, name: ui.catalog.systems.detailLabel, item: new URL('/systems/', Astro.site).toString() },
|
|
||||||
{ '@type': 'ListItem', position: 3, name: system.name, item: new URL(`/systems/${system.slug}/`, Astro.site).toString() },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'CreativeWork',
|
|
||||||
name: system.name,
|
|
||||||
description,
|
|
||||||
url: new URL(`/systems/${system.slug}/`, Astro.site).toString(),
|
|
||||||
license: 'https://www.apache.org/licenses/LICENSE-2.0',
|
|
||||||
genre: system.categoryLabel,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
|
|
||||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
|
||||||
<a href={href('/')}>Open Design</a>
|
|
||||||
<span>/</span>
|
|
||||||
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span aria-current="page">{system.name}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<article class="detail">
|
|
||||||
<header class="detail-head">
|
|
||||||
<span class="label">
|
|
||||||
{ui.catalog.systems.detailLabel}
|
|
||||||
<span class="ix">· {system.categoryLabel}</span>
|
|
||||||
</span>
|
|
||||||
<h1 class="display">{system.name}<span class="dot">.</span></h1>
|
|
||||||
{system.tagline && <p class="lead">{system.tagline}</p>}
|
|
||||||
<div class="detail-actions">
|
|
||||||
<a class="btn btn-primary" href={system.source} target="_blank" rel="noopener">
|
|
||||||
{ui.catalog.systems.viewOnGithub}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{system.palette.length > 0 && (
|
|
||||||
<section class="detail-block">
|
|
||||||
<h2>{ui.catalog.systems.paletteSample}</h2>
|
|
||||||
<p class="block-lead">
|
|
||||||
{ui.catalog.systems.paletteLead(system.palette.length)}
|
|
||||||
</p>
|
|
||||||
<div class="palette-row">
|
|
||||||
{system.palette.map((hex) => (
|
|
||||||
<div class="palette-cell">
|
|
||||||
<span class="swatch" style={`background:${hex}`} />
|
|
||||||
<code>{hex}</code>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{system.atmosphere && (
|
|
||||||
<section class="detail-block">
|
|
||||||
<h2>{ui.catalog.systems.visualTheme}</h2>
|
|
||||||
<p class="atmosphere">{system.atmosphere}</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{related.length > 0 && (
|
|
||||||
<section class="detail-block">
|
|
||||||
<h2>{ui.catalog.systems.related(system.categoryLabel)}</h2>
|
|
||||||
<ul class="related-grid">
|
|
||||||
{related.map((r) => (
|
|
||||||
<li>
|
|
||||||
<a href={href(`/systems/${r.slug}/`)}>
|
|
||||||
<span class="related-name">{r.name}</span>
|
|
||||||
<span class="related-desc">{r.tagline}</span>
|
|
||||||
<div class="system-swatches" aria-hidden="true">
|
|
||||||
{r.palette.slice(0, 4).map((hex) => (
|
|
||||||
<span class="swatch" style={`background:${hex}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* /systems/category/<slug>/ — every design system grouped by category
|
|
||||||
* (AI & LLM, Productivity & SaaS, Editorial, Brand, ...).
|
|
||||||
*/
|
|
||||||
import Layout from '../../../_components/sub-page-layout.astro';
|
|
||||||
import SystemCard from '../../../_components/system-card.astro';
|
|
||||||
import {
|
|
||||||
getSystemCategoryIndex,
|
|
||||||
getSystemsForCategory,
|
|
||||||
type TagDescriptor,
|
|
||||||
} from '../../../_lib/catalog';
|
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const tags = await getSystemCategoryIndex();
|
|
||||||
return tags.map((tag) => ({
|
|
||||||
params: { category: tag.slug },
|
|
||||||
props: { tag },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tag: TagDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tag } = Astro.props as Props;
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const { records, label } = await getSystemsForCategory(tag.slug, locale);
|
|
||||||
const heading = label ?? tag.label;
|
|
||||||
|
|
||||||
const title = ui.catalog.systems.categoryHeading(heading, records.length);
|
|
||||||
const description = ui.catalog.systems.categoryDescription(heading, records.length);
|
|
||||||
|
|
||||||
const url = new URL(`/systems/category/${tag.slug}/`, Astro.site).toString();
|
|
||||||
const jsonLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'CollectionPage',
|
|
||||||
name: title,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
numberOfItems: records.length,
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
|
|
||||||
<header class="catalog-head">
|
|
||||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
|
||||||
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<span>{ui.catalog.systems.category}</span>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<span class="crumb-active">{heading}</span>
|
|
||||||
</nav>
|
|
||||||
<span class="label">{ui.catalog.systems.label}</span>
|
|
||||||
<h1 class="display">
|
|
||||||
{ui.catalog.systems.categoryHeading(heading, records.length)}
|
|
||||||
</h1>
|
|
||||||
<p class="lead">
|
|
||||||
{ui.catalog.systems.categoryLead(label ?? tag.label)}
|
|
||||||
</p>
|
|
||||||
<p class="filter-clear">
|
|
||||||
<a href={href('/systems/')}>{ui.catalog.systems.allSystems}</a>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
|
||||||
<ul>
|
|
||||||
{records.map((s) => <SystemCard system={s} />)}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* /systems/ — index of every portable design system in the repo.
|
|
||||||
*/
|
|
||||||
import Layout from '../../_components/sub-page-layout.astro';
|
|
||||||
import SystemCard from '../../_components/system-card.astro';
|
|
||||||
import { getSystemRecords, getSystemCategoryIndex } from '../../_lib/catalog';
|
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
|
||||||
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const systems = await getSystemRecords(locale);
|
|
||||||
|
|
||||||
const categoryTags = await getSystemCategoryIndex(locale);
|
|
||||||
|
|
||||||
const title = ui.catalog.systems.title(systems.length);
|
|
||||||
const description = ui.catalog.systems.description;
|
|
||||||
|
|
||||||
const jsonLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'CollectionPage',
|
|
||||||
name: title,
|
|
||||||
description,
|
|
||||||
url: new URL('/systems/', Astro.site).toString(),
|
|
||||||
numberOfItems: systems.length,
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
|
|
||||||
<header class="catalog-head">
|
|
||||||
<span class="label">{ui.catalog.systems.label}</span>
|
|
||||||
<h1 class="display">
|
|
||||||
{ui.catalog.systems.heading(systems.length)}
|
|
||||||
</h1>
|
|
||||||
<p class="lead">
|
|
||||||
{ui.catalog.systems.lead}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">{ui.catalog.systems.category}</span>
|
|
||||||
<ul>
|
|
||||||
{categoryTags.map((tag) => (
|
|
||||||
<li>
|
|
||||||
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
|
|
||||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
|
||||||
<ul>
|
|
||||||
{systems.map((s) => <SystemCard system={s} />)}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
---
|
|
||||||
/*
|
|
||||||
* /templates/<slug>/ — detail page for renderable design templates and
|
|
||||||
* legacy Live Artifact template bundles.
|
|
||||||
*/
|
|
||||||
import Layout from '../../../_components/sub-page-layout.astro';
|
|
||||||
import LazyImg from '../../../_components/lazy-img.astro';
|
|
||||||
import { getTemplateRecords, type TemplateRecord } from '../../../_lib/catalog';
|
|
||||||
import {
|
|
||||||
getLandingUiCopy,
|
|
||||||
localeFromPath,
|
|
||||||
localizedHref,
|
|
||||||
type LandingLocaleCode,
|
|
||||||
} from '../../../i18n';
|
|
||||||
|
|
||||||
/* See pages/skills/[slug]/index.astro for the rationale on why these
|
|
||||||
* tables live inline rather than in the global UI bundle. Same shape,
|
|
||||||
* just keyed for the templates surface. */
|
|
||||||
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
|
|
||||||
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
|
|
||||||
en: ({ name, description, url }) => `🎨 Just forked ${name} from @opendesignai — the open-source Claude Design alternative.
|
|
||||||
✨ Templates as files, not vendor docs. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
zh: ({ name, description, url }) => `🎨 fork 了一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
|
|
||||||
✨ 模板就是文件,不是 vendor 数据。Fork → 换数据 → 发。
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
'zh-tw': ({ name, description, url }) => `🎨 fork 了一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
|
|
||||||
✨ 模板就是檔案,不是 vendor 資料。Fork → 換資料 → 發佈。
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ja: ({ name, description, url }) => `🎨 @opendesignai の ${name} を fork —— オープンソースの Claude Design 代替。
|
|
||||||
✨ テンプレートはファイル、ベンダー DB じゃない。Fork → 差し替え → 出荷。
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ko: ({ name, description, url }) => `🎨 @opendesignai의 ${name} fork —— 오픈 소스 Claude Design 대안.
|
|
||||||
✨ 템플릿은 파일, 벤더 DB가 아닙니다. Fork → 교체 → 출시.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
de: ({ name, description, url }) => `🎨 Gerade ${name} von @opendesignai geforkt — die Open-Source-Alternative zu Claude Design.
|
|
||||||
✨ Vorlagen als Dateien, nicht als Vendor-DB. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
fr: ({ name, description, url }) => `🎨 Je viens de forker ${name} de @opendesignai — l'alternative open-source à Claude Design.
|
|
||||||
✨ Modèles = fichiers, pas une base vendeur. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ru: ({ name, description, url }) => `🎨 Форкнул ${name} с @opendesignai — open-source альтернативу Claude Design.
|
|
||||||
✨ Шаблоны — это файлы, не vendor-DB. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
es: ({ name, description, url }) => `🎨 Acabo de hacer fork de ${name} en @opendesignai — la alternativa open-source a Claude Design.
|
|
||||||
✨ Plantillas como archivos, no como vendor DB. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
'pt-br': ({ name, description, url }) => `🎨 Acabei de dar fork em ${name} do @opendesignai — a alternativa open-source ao Claude Design.
|
|
||||||
✨ Templates como arquivos, não como vendor DB. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
it: ({ name, description, url }) => `🎨 Ho appena forkato ${name} da @opendesignai — l'alternativa open-source a Claude Design.
|
|
||||||
✨ Template come file, non come DB vendor. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
vi: ({ name, description, url }) => `🎨 Vừa fork ${name} từ @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
|
|
||||||
✨ Template là file, không phải DB của vendor. Fork → đổi data → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
pl: ({ name, description, url }) => `🎨 Właśnie sforkowałem ${name} z @opendesignai — open-source'ową alternatywę dla Claude Design.
|
|
||||||
✨ Szablony jako pliki, nie vendor DB. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
id: ({ name, description, url }) => `🎨 Baru fork ${name} dari @opendesignai — alternatif open-source untuk Claude Design.
|
|
||||||
✨ Template itu file, bukan vendor DB. Fork → tukar data → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
nl: ({ name, description, url }) => `🎨 Net ${name} geforkt van @opendesignai — het open-source alternatief voor Claude Design.
|
|
||||||
✨ Templates als bestanden, niet als vendor-DB. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
ar: ({ name, description, url }) => `🎨 fork للتو ${name} من @opendesignai — البديل مفتوح المصدر لـ Claude Design.
|
|
||||||
✨ القوالب ملفات، ليست قاعدة بيانات للمزوّد. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
tr: ({ name, description, url }) => `🎨 ${name} fork'ladım (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
|
|
||||||
✨ Şablonlar dosya, vendor DB değil. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
uk: ({ name, description, url }) => `🎨 Форкнув ${name} з @opendesignai — open-source альтернативу Claude Design.
|
|
||||||
✨ Шаблони — це файли, а не vendor-DB. Fork → swap → ship.
|
|
||||||
|
|
||||||
→ ${url}`,
|
|
||||||
};
|
|
||||||
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
|
|
||||||
en: { title: 'Share this template', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
|
|
||||||
zh: { title: '分享这个模板', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
|
|
||||||
'zh-tw': { title: '分享這個模板', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
|
|
||||||
ja: { title: 'このテンプレートを共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
|
|
||||||
ko: { title: '이 템플릿 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
|
|
||||||
de: { title: 'Diese Vorlage teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
|
|
||||||
fr: { title: 'Partager ce modèle', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
|
|
||||||
ru: { title: 'Поделиться шаблоном', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
|
|
||||||
es: { title: 'Compartir plantilla', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
|
|
||||||
'pt-br': { title: 'Compartilhar template', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
|
|
||||||
it: { title: 'Condividi il modello', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
|
|
||||||
vi: { title: 'Chia sẻ template', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
|
|
||||||
pl: { title: 'Udostępnij szablon', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
|
|
||||||
id: { title: 'Bagikan template ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
|
|
||||||
nl: { title: 'Deel deze template', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
|
|
||||||
ar: { title: 'شارك هذا القالب', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
|
|
||||||
tr: { title: 'Bu şablonu paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
|
|
||||||
uk: { title: 'Поділитись шаблоном', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const records = await getTemplateRecords();
|
|
||||||
return records.map((template) => ({
|
|
||||||
params: { slug: template.slug },
|
|
||||||
props: { template },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
template: TemplateRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { template: routeTemplate } = Astro.props as Props;
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const localizedTemplates = locale === 'en' ? [] : await getTemplateRecords(locale);
|
|
||||||
const template =
|
|
||||||
localizedTemplates.find((item) => item.slug === routeTemplate.slug) ?? routeTemplate;
|
|
||||||
|
|
||||||
const title = ui.catalog.templates.detailTitle(template.name);
|
|
||||||
const description = template.summary;
|
|
||||||
|
|
||||||
const templateUrl = `https://open-design.ai/templates/${template.slug}/`;
|
|
||||||
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
|
|
||||||
name: template.name,
|
|
||||||
description: template.summary,
|
|
||||||
url: templateUrl,
|
|
||||||
});
|
|
||||||
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
|
|
||||||
const originLabel =
|
|
||||||
template.origin === 'live-artifact'
|
|
||||||
? ui.catalog.templates.liveArtifact
|
|
||||||
: ui.catalog.templates.skillTemplate;
|
|
||||||
const files =
|
|
||||||
template.origin === 'live-artifact'
|
|
||||||
? [
|
|
||||||
['template.html', ui.catalog.templates.renderer],
|
|
||||||
['data.json', ui.catalog.templates.seedData],
|
|
||||||
['README.md', ui.catalog.templates.readme],
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
['SKILL.md', ui.catalog.skills.detailLabel],
|
|
||||||
['example.html', ui.catalog.templates.previewCaption],
|
|
||||||
['assets/', ui.catalog.templates.detailLabel],
|
|
||||||
['references/', ui.catalog.craft.detailLabel],
|
|
||||||
];
|
|
||||||
|
|
||||||
const jsonLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'BreadcrumbList',
|
|
||||||
itemListElement: [
|
|
||||||
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
|
|
||||||
{ '@type': 'ListItem', position: 2, name: ui.catalog.templates.detailLabel, item: new URL('/templates/', Astro.site).toString() },
|
|
||||||
{ '@type': 'ListItem', position: 3, name: template.name, item: new URL(template.detailHref, Astro.site).toString() },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
|
|
||||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
|
||||||
<a href={href('/')}>Open Design</a>
|
|
||||||
<span>/</span>
|
|
||||||
<a href={href('/templates/')}>{ui.catalog.templates.detailLabel}</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span aria-current="page">{template.name}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<article class="detail">
|
|
||||||
<header class="detail-head">
|
|
||||||
<span class="label">
|
|
||||||
{ui.catalog.templates.detailLabel}
|
|
||||||
<span class="ix">· {originLabel}</span>
|
|
||||||
</span>
|
|
||||||
<h1 class="display">{template.name}<span class="dot">.</span></h1>
|
|
||||||
<p class="lead">{template.summary}</p>
|
|
||||||
{(template.mode || template.platform || template.scenario) && (
|
|
||||||
<dl class="detail-meta">
|
|
||||||
{template.mode && (
|
|
||||||
<>
|
|
||||||
<dt>{ui.catalog.skills.mode}</dt>
|
|
||||||
<dd>{template.modeLabel ?? template.mode}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{template.platform && (
|
|
||||||
<>
|
|
||||||
<dt>{ui.catalog.skills.platform}</dt>
|
|
||||||
<dd>{template.platformLabel ?? template.platform}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{template.scenario && (
|
|
||||||
<>
|
|
||||||
<dt>{ui.catalog.skills.scenario}</dt>
|
|
||||||
<dd>{template.scenarioLabel ?? template.scenario}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
<div class="detail-actions">
|
|
||||||
{/* Two CTAs matching skills/[slug]: "Use this template" sends
|
|
||||||
users to the OD desktop release page (install first, then
|
|
||||||
use the template); "Find on GitHub" deep-links to the
|
|
||||||
source folder. See skills/[slug].astro for the broader
|
|
||||||
rationale on the release-page pivot. */}
|
|
||||||
<a
|
|
||||||
class="btn btn-primary"
|
|
||||||
href="https://github.com/nexu-io/open-design/releases"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Use this template →
|
|
||||||
</a>
|
|
||||||
<a class="btn btn-ghost" href={template.source} target="_blank" rel="noopener">
|
|
||||||
Find on GitHub →
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost detail-share-trigger"
|
|
||||||
data-share-open={`template:${template.slug}`}
|
|
||||||
>
|
|
||||||
{shareUi.openLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{template.previewUrl && (
|
|
||||||
<figure class="detail-preview">
|
|
||||||
{/* Click-to-expand: thumb is the summary; clicking opens the
|
|
||||||
live iframe rendering the canonical artifact. Skill-template
|
|
||||||
origin → /skills/<slug>/example.html; live-artifact origin
|
|
||||||
→ /templates/<slug>/preview.html. */}
|
|
||||||
<details class="detail-preview-live">
|
|
||||||
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${template.name}`}>
|
|
||||||
<LazyImg
|
|
||||||
src={template.previewUrl}
|
|
||||||
alt={`${template.name} preview`}
|
|
||||||
loading="priority"
|
|
||||||
/>
|
|
||||||
<span class="detail-preview-thumb-overlay" aria-hidden="true">
|
|
||||||
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div class="detail-preview-frame-wrap">
|
|
||||||
<iframe
|
|
||||||
src={
|
|
||||||
template.origin === 'live-artifact'
|
|
||||||
? `/templates/${template.slug}/preview.html`
|
|
||||||
: `/skills/${template.slug}/example.html`
|
|
||||||
}
|
|
||||||
title={`${template.name} interactive preview`}
|
|
||||||
loading="lazy"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
class="detail-preview-frame"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
class="detail-preview-popout"
|
|
||||||
href={
|
|
||||||
template.origin === 'live-artifact'
|
|
||||||
? `/templates/${template.slug}/preview.html`
|
|
||||||
: `/skills/${template.slug}/example.html`
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
aria-label="Open preview in new tab"
|
|
||||||
>
|
|
||||||
Open in new tab ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<figcaption>{ui.catalog.templates.previewCaption}</figcaption>
|
|
||||||
</figure>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Share modal — same shape as skills/[slug]; see that file for the
|
|
||||||
copy-then-paste rationale and SEO keyword choice. */}
|
|
||||||
<dialog
|
|
||||||
class="detail-share-dialog"
|
|
||||||
data-share-dialog={`template:${template.slug}`}
|
|
||||||
>
|
|
||||||
<form method="dialog" class="detail-share-dialog-form">
|
|
||||||
<header class="detail-share-dialog-head">
|
|
||||||
<h2>{shareUi.title}</h2>
|
|
||||||
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
|
|
||||||
</header>
|
|
||||||
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
|
|
||||||
<textarea
|
|
||||||
class="detail-share-dialog-text"
|
|
||||||
readonly
|
|
||||||
rows="6"
|
|
||||||
data-share-text
|
|
||||||
>{shareCopy}</textarea>
|
|
||||||
<div class="detail-share-dialog-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary detail-share-dialog-copy"
|
|
||||||
data-share-copy
|
|
||||||
>
|
|
||||||
{shareUi.copyText}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost detail-share-dialog-copy-link"
|
|
||||||
data-copy-link={templateUrl}
|
|
||||||
>
|
|
||||||
{shareUi.copyLink}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="detail-share-dialog-platforms">
|
|
||||||
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
|
|
||||||
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
|
|
||||||
<span class="sr-only">X</span>
|
|
||||||
</a>
|
|
||||||
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
|
||||||
<span class="sr-only">LinkedIn</span>
|
|
||||||
</a>
|
|
||||||
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
|
|
||||||
<span class="sr-only">Reddit</span>
|
|
||||||
</a>
|
|
||||||
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
|
||||||
<span class="sr-only">Facebook</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<section class="detail-block">
|
|
||||||
<h2>{ui.catalog.templates.whatsInside}</h2>
|
|
||||||
<p class="block-lead">
|
|
||||||
{ui.catalog.templates.whatsInsideLead}
|
|
||||||
</p>
|
|
||||||
<ul class="trigger-list">
|
|
||||||
{files.map(([name, copy]) => (
|
|
||||||
<li><code>{name}</code> — {copy}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
---
|
|
||||||
import Layout from '../../_components/sub-page-layout.astro';
|
|
||||||
import LazyImg from '../../_components/lazy-img.astro';
|
|
||||||
import { getTemplateRecords } from '../../_lib/catalog';
|
|
||||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
|
||||||
|
|
||||||
const locale = localeFromPath(Astro.url.pathname);
|
|
||||||
const ui = getLandingUiCopy(locale);
|
|
||||||
const href = (path: string) => localizedHref(path, locale);
|
|
||||||
const templates = await getTemplateRecords(locale);
|
|
||||||
|
|
||||||
const title = ui.catalog.templates.title(templates.length);
|
|
||||||
const description = ui.catalog.templates.description;
|
|
||||||
|
|
||||||
const jsonLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'CollectionPage',
|
|
||||||
name: title,
|
|
||||||
description,
|
|
||||||
url: new URL('/templates/', Astro.site).toString(),
|
|
||||||
numberOfItems: templates.length,
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
|
|
||||||
<header class="catalog-head">
|
|
||||||
<span class="label">{ui.catalog.templates.label}</span>
|
|
||||||
<h1 class="display">
|
|
||||||
{ui.catalog.templates.heading(templates.length)}
|
|
||||||
</h1>
|
|
||||||
<p class="lead">
|
|
||||||
{ui.catalog.templates.lead}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="template-grid" aria-label={ui.catalog.templates.allAria}>
|
|
||||||
<ul>
|
|
||||||
{templates.map((t, i) => (
|
|
||||||
<li class="template-card">
|
|
||||||
<a href={href(t.detailHref)}>
|
|
||||||
{t.previewUrl ? (
|
|
||||||
<span class="template-thumb">
|
|
||||||
<LazyImg src={t.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span class="template-thumb template-thumb-empty" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
<span class={`meta-tag ${t.origin === 'live-artifact' ? 'coral' : ''}`}>
|
|
||||||
{t.origin === 'live-artifact' ? ui.catalog.templates.liveArtifact : (t.modeLabel ?? ui.catalog.templates.skillTemplate)}
|
|
||||||
</span>
|
|
||||||
<span class="template-name">{t.name}</span>
|
|
||||||
<p class="template-summary">{t.summary}</p>
|
|
||||||
{(t.platform || t.scenario) && (
|
|
||||||
<span class="template-meta-line">
|
|
||||||
{[t.platformLabel ?? t.platform, t.scenarioLabel ?? t.scenario].filter(Boolean).join(' · ')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -257,11 +257,11 @@ export default defineConfig({
|
||||||
item.priority = 0.9;
|
item.priority = 0.9;
|
||||||
item.changefreq = changefreq.weekly;
|
item.changefreq = changefreq.weekly;
|
||||||
} else if (
|
} else if (
|
||||||
path === '/skills/' ||
|
|
||||||
path === '/systems/' ||
|
|
||||||
path === '/templates/' ||
|
|
||||||
path === '/craft/' ||
|
path === '/craft/' ||
|
||||||
path === '/plugins/'
|
path === '/plugins/' ||
|
||||||
|
path === '/plugins/skills/' ||
|
||||||
|
path === '/plugins/systems/' ||
|
||||||
|
path === '/plugins/templates/'
|
||||||
) {
|
) {
|
||||||
item.priority = 0.7;
|
item.priority = 0.7;
|
||||||
item.changefreq = changefreq.weekly;
|
item.changefreq = changefreq.weekly;
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,85 @@
|
||||||
/fa/plugins/* /plugins/:splat 301
|
/fa/plugins/* /plugins/:splat 301
|
||||||
/hu/plugins/* /plugins/:splat 301
|
/hu/plugins/* /plugins/:splat 301
|
||||||
/th/plugins/* /plugins/:splat 301
|
/th/plugins/* /plugins/:splat 301
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Catalog migration: legacy /skills /systems /templates -> /plugins/*
|
||||||
|
# The old Astro generators were removed; these 301s preserve inbound
|
||||||
|
# links and SEO equity. Cloudflare matches first rule wins, so order is:
|
||||||
|
# faceted/specific -> detail prefixes -> bare index -> locale variants.
|
||||||
|
# trailingSlash:'always', so every source and target ends in '/'.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Faceted pages have no new equivalent -> degrade to the section landing.
|
||||||
|
/skills/mode/* /plugins/skills/ 301
|
||||||
|
/skills/scenario/* /plugins/skills/ 301
|
||||||
|
/systems/category/* /plugins/systems/ 301
|
||||||
|
|
||||||
|
# Systems detail: design-system-<folder> is the uniform new slug.
|
||||||
|
# These 8 folders have no new detail page -> degrade (must precede splat).
|
||||||
|
/systems/cisco/ /plugins/systems/ 301
|
||||||
|
/systems/hud/ /plugins/systems/ 301
|
||||||
|
/systems/loom/ /plugins/systems/ 301
|
||||||
|
/systems/perplexity/ /plugins/systems/ 301
|
||||||
|
/systems/slack/ /plugins/systems/ 301
|
||||||
|
/systems/trading-terminal/ /plugins/systems/ 301
|
||||||
|
/systems/webex/ /plugins/systems/ 301
|
||||||
|
/systems/wechat/ /plugins/systems/ 301
|
||||||
|
/systems/* /plugins/design-system-:splat 301
|
||||||
|
|
||||||
|
# Templates detail: example-<folder> is the uniform new slug.
|
||||||
|
/templates/live-otd-operations-brief/ /plugins/templates/ 301
|
||||||
|
/templates/* /plugins/example-:splat 301
|
||||||
|
|
||||||
|
# Skills detail: only these 27 have a new artifact-template equivalent.
|
||||||
|
# 'replicate' collides with design-system-replicate -> force the section.
|
||||||
|
/skills/replicate/ /plugins/skills/ 301
|
||||||
|
/skills/article-magazine/ /plugins/example-article-magazine/ 301
|
||||||
|
/skills/card-twitter/ /plugins/example-card-twitter/ 301
|
||||||
|
/skills/card-xiaohongshu/ /plugins/example-card-xiaohongshu/ 301
|
||||||
|
/skills/data-report/ /plugins/example-data-report/ 301
|
||||||
|
/skills/deck-guizang-editorial/ /plugins/example-deck-guizang-editorial/ 301
|
||||||
|
/skills/deck-open-slide-canvas/ /plugins/example-deck-open-slide-canvas/ 301
|
||||||
|
/skills/deck-swiss-international/ /plugins/example-deck-swiss-international/ 301
|
||||||
|
/skills/design-brief/ /plugins/example-design-brief/ 301
|
||||||
|
/skills/doc-kami-parchment/ /plugins/example-doc-kami-parchment/ 301
|
||||||
|
/skills/frame-data-chart-nyt/ /plugins/example-frame-data-chart-nyt/ 301
|
||||||
|
/skills/frame-flowchart-sticky/ /plugins/example-frame-flowchart-sticky/ 301
|
||||||
|
/skills/frame-glitch-title/ /plugins/example-frame-glitch-title/ 301
|
||||||
|
/skills/frame-light-leak-cinema/ /plugins/example-frame-light-leak-cinema/ 301
|
||||||
|
/skills/frame-liquid-bg-hero/ /plugins/example-frame-liquid-bg-hero/ 301
|
||||||
|
/skills/frame-logo-outro/ /plugins/example-frame-logo-outro/ 301
|
||||||
|
/skills/frame-macos-notification/ /plugins/example-frame-macos-notification/ 301
|
||||||
|
/skills/hatch-pet/ /plugins/example-hatch-pet/ 301
|
||||||
|
/skills/mockup-device-3d/ /plugins/example-mockup-device-3d/ 301
|
||||||
|
/skills/poster-hero/ /plugins/example-poster-hero/ 301
|
||||||
|
/skills/ppt-keynote/ /plugins/example-ppt-keynote/ 301
|
||||||
|
/skills/pptx-html-fidelity-audit/ /plugins/example-pptx-html-fidelity-audit/ 301
|
||||||
|
/skills/resume-modern/ /plugins/example-resume-modern/ 301
|
||||||
|
/skills/social-reddit-card/ /plugins/example-social-reddit-card/ 301
|
||||||
|
/skills/social-spotify-card/ /plugins/example-social-spotify-card/ 301
|
||||||
|
/skills/social-x-post-card/ /plugins/example-social-x-post-card/ 301
|
||||||
|
/skills/vfx-text-cursor/ /plugins/example-vfx-text-cursor/ 301
|
||||||
|
/skills/video-hyperframes/ /plugins/example-video-hyperframes/ 301
|
||||||
|
# Remaining ~110 instruction-only skills have no detail page -> section.
|
||||||
|
/skills/* /plugins/skills/ 301
|
||||||
|
|
||||||
|
# Bare catalog index pages (least specific -> last).
|
||||||
|
/skills/ /plugins/skills/ 301
|
||||||
|
/systems/ /plugins/systems/ 301
|
||||||
|
/templates/ /plugins/templates/ 301
|
||||||
|
|
||||||
|
# Locale-prefixed variants (active LANDING_LOCALES minus en: zh zh-tw ja ko).
|
||||||
|
# Non-en pages are sitemap-excluded; degrade to the section (no detail precision).
|
||||||
|
/zh/skills/* /zh/plugins/skills/ 301
|
||||||
|
/zh/systems/* /zh/plugins/systems/ 301
|
||||||
|
/zh/templates/* /zh/plugins/templates/ 301
|
||||||
|
/zh-tw/skills/* /zh-tw/plugins/skills/ 301
|
||||||
|
/zh-tw/systems/* /zh-tw/plugins/systems/ 301
|
||||||
|
/zh-tw/templates/* /zh-tw/plugins/templates/ 301
|
||||||
|
/ja/skills/* /ja/plugins/skills/ 301
|
||||||
|
/ja/systems/* /ja/plugins/systems/ 301
|
||||||
|
/ja/templates/* /ja/plugins/templates/ 301
|
||||||
|
/ko/skills/* /ko/plugins/skills/ 301
|
||||||
|
/ko/systems/* /ko/plugins/systems/ 301
|
||||||
|
/ko/templates/* /ko/plugins/templates/ 301
|
||||||
|
|
|
||||||
|
|
@ -1622,6 +1622,7 @@ function AppInner() {
|
||||||
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
|
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
|
||||||
mediaProvidersNotice={mediaProvidersNotice}
|
mediaProvidersNotice={mediaProvidersNotice}
|
||||||
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
|
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
|
||||||
|
onProjectsRefresh={refreshProjects}
|
||||||
onSkillsChanged={handleSkillsChanged}
|
onSkillsChanged={handleSkillsChanged}
|
||||||
onDesignSystemsChanged={handleDesignSystemsChanged}
|
onDesignSystemsChanged={handleDesignSystemsChanged}
|
||||||
providerModelsCache={providerModelsCache}
|
providerModelsCache={providerModelsCache}
|
||||||
|
|
|
||||||
|
|
@ -2658,6 +2658,11 @@ function ToolsPluginsPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="composer-tools-row-main"
|
className="composer-tools-row-main"
|
||||||
|
// Match the @-mention popover: prevent the textarea from
|
||||||
|
// losing focus before the click handler runs so
|
||||||
|
// selectionStart isn't reset to 0 and the inserted token
|
||||||
|
// lands at the user's actual cursor position (#3195).
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setPendingId(p.id);
|
setPendingId(p.id);
|
||||||
try {
|
try {
|
||||||
|
|
@ -2749,6 +2754,10 @@ function ToolsMcpPanel({
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="composer-tools-row"
|
className="composer-tools-row"
|
||||||
|
// Match the @-mention popover: prevent the textarea from
|
||||||
|
// losing focus before the click handler runs so
|
||||||
|
// selectionStart isn't reset to 0 (#3195).
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onInsert(s.id)}
|
onClick={() => onInsert(s.id)}
|
||||||
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
|
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
|
||||||
>
|
>
|
||||||
|
|
@ -2839,6 +2848,10 @@ function ToolsSkillsPanel({
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={`composer-tools-row${active ? ' active' : ''}`}
|
className={`composer-tools-row${active ? ' active' : ''}`}
|
||||||
|
// Match the @-mention popover: prevent the textarea from
|
||||||
|
// losing focus before the click handler runs so
|
||||||
|
// selectionStart isn't reset to 0 (#3195).
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setPendingId(skill.id);
|
setPendingId(skill.id);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2014,6 +2014,16 @@ export function conversationMetaLabel(
|
||||||
t: TranslateFn,
|
t: TranslateFn,
|
||||||
): string {
|
): string {
|
||||||
const latestRun = conversation.latestRun;
|
const latestRun = conversation.latestRun;
|
||||||
|
if (
|
||||||
|
latestRun &&
|
||||||
|
(latestRun.status === 'succeeded' ||
|
||||||
|
latestRun.status === 'failed' ||
|
||||||
|
latestRun.status === 'canceled') &&
|
||||||
|
typeof conversation.totalDurationMs === 'number' &&
|
||||||
|
Number.isFinite(conversation.totalDurationMs)
|
||||||
|
) {
|
||||||
|
return formatDurationShort(conversation.totalDurationMs);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
latestRun &&
|
latestRun &&
|
||||||
(latestRun.status === 'succeeded' ||
|
(latestRun.status === 'succeeded' ||
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,7 @@ interface Props {
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'pet'
|
| 'pet'
|
||||||
|
| 'projectLocations'
|
||||||
| 'library'
|
| 'library'
|
||||||
| 'about'
|
| 'about'
|
||||||
| 'memory'
|
| 'memory'
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ interface Props {
|
||||||
onOpenDesignSystem?: (id: string) => void;
|
onOpenDesignSystem?: (id: string) => void;
|
||||||
onDesignSystemsRefresh?: () => Promise<void> | void;
|
onDesignSystemsRefresh?: () => Promise<void> | void;
|
||||||
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
|
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
|
||||||
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'library' | 'about' | 'memory' | 'designSystems') => void;
|
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'projectLocations' | 'library' | 'about' | 'memory' | 'designSystems') => void;
|
||||||
onCompleteOnboarding: () => void;
|
onCompleteOnboarding: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
|
||||||
};
|
};
|
||||||
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
||||||
type PreviewCanvasSize = { width: number; height: number };
|
type PreviewCanvasSize = { width: number; height: number };
|
||||||
|
type CommentPreviewCanvasOptions = {
|
||||||
|
boardMode: boolean;
|
||||||
|
sidePanelCollapsed: boolean;
|
||||||
|
viewport?: PreviewViewportId;
|
||||||
|
};
|
||||||
|
type PreviewScaleOptions = {
|
||||||
|
canvasPadding?: number;
|
||||||
|
};
|
||||||
type PreviewViewportPreset = {
|
type PreviewViewportPreset = {
|
||||||
id: PreviewViewportId;
|
id: PreviewViewportId;
|
||||||
width: number | null;
|
width: number | null;
|
||||||
|
|
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
|
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
|
||||||
|
const COMMENT_SIDE_DOCK_WIDTH = 320;
|
||||||
|
const COMMENT_SIDE_DOCK_RAIL_WIDTH = 42;
|
||||||
|
const COMMENT_SIDE_DOCK_GAP = 12;
|
||||||
|
const COMMENT_SIDE_DOCK_PADDING = 8;
|
||||||
|
const COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING = 24;
|
||||||
|
const COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH = 280;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT = 220;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT = 48;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION =
|
||||||
|
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION =
|
||||||
|
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT;
|
||||||
|
|
||||||
// The five basic style facets the inspect panel exposes. Kept narrow on
|
// The five basic style facets the inspect panel exposes. Kept narrow on
|
||||||
// purpose — open-slide's design tokens panel only edits global tokens, so
|
// purpose — open-slide's design tokens panel only edits global tokens, so
|
||||||
|
|
@ -500,10 +520,11 @@ function previewViewportStyle(
|
||||||
viewport: PreviewViewportId,
|
viewport: PreviewViewportId,
|
||||||
previewScale = 1,
|
previewScale = 1,
|
||||||
canvasSize?: PreviewCanvasSize,
|
canvasSize?: PreviewCanvasSize,
|
||||||
|
options?: PreviewScaleOptions,
|
||||||
): CSSProperties & Record<string, string | number> {
|
): CSSProperties & Record<string, string | number> {
|
||||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
|
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
|
||||||
if (!preset.width) return {};
|
if (!preset.width) return {};
|
||||||
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
|
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
|
||||||
return {
|
return {
|
||||||
'--preview-viewport-width': `${preset.width}px`,
|
'--preview-viewport-width': `${preset.width}px`,
|
||||||
'--preview-viewport-height': `${preset.height}px`,
|
'--preview-viewport-height': `${preset.height}px`,
|
||||||
|
|
@ -512,15 +533,54 @@ function previewViewportStyle(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function commentPreviewCanvasSize(
|
||||||
|
canvasSize: PreviewCanvasSize | undefined,
|
||||||
|
options: CommentPreviewCanvasOptions,
|
||||||
|
): PreviewCanvasSize | undefined {
|
||||||
|
if (!canvasSize || !options.boardMode) return canvasSize;
|
||||||
|
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||||
|
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||||
|
: COMMENT_SIDE_DOCK_PADDING;
|
||||||
|
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||||
|
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||||
|
if (usesStackedCommentSideDock(canvasSize, options)) {
|
||||||
|
const stackedHeightDeduction = options.sidePanelCollapsed
|
||||||
|
? COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION
|
||||||
|
: COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, canvasSize.width - (COMMENT_SIDE_DOCK_PADDING * 2)),
|
||||||
|
height: Math.max(1, canvasSize.height - stackedHeightDeduction),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: Math.max(1, dockedWidth),
|
||||||
|
height: Math.max(1, canvasSize.height - (dockPadding * 2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesStackedCommentSideDock(
|
||||||
|
canvasSize: PreviewCanvasSize | undefined,
|
||||||
|
options: CommentPreviewCanvasOptions,
|
||||||
|
) {
|
||||||
|
if (!canvasSize || !options.boardMode) return false;
|
||||||
|
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||||
|
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||||
|
: COMMENT_SIDE_DOCK_PADDING;
|
||||||
|
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||||
|
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||||
|
return dockedWidth < COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
export function effectivePreviewScale(
|
export function effectivePreviewScale(
|
||||||
viewport: PreviewViewportId,
|
viewport: PreviewViewportId,
|
||||||
previewScale: number,
|
previewScale: number,
|
||||||
canvasSize?: PreviewCanvasSize,
|
canvasSize?: PreviewCanvasSize,
|
||||||
|
options?: PreviewScaleOptions,
|
||||||
) {
|
) {
|
||||||
if (viewport === 'desktop') return previewScale;
|
if (viewport === 'desktop') return previewScale;
|
||||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
|
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
|
||||||
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
|
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
|
||||||
const canvasPadding = 48;
|
const canvasPadding = options?.canvasPadding ?? 48;
|
||||||
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
|
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
|
||||||
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
|
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
|
||||||
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
|
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
|
||||||
|
|
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
|
||||||
activeCommentId,
|
activeCommentId,
|
||||||
collapsed,
|
collapsed,
|
||||||
onCollapsedChange,
|
onCollapsedChange,
|
||||||
onClose,
|
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
|
|
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
|
||||||
activeCommentId: string | null;
|
activeCommentId: string | null;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onCollapsedChange: (collapsed: boolean) => void;
|
onCollapsedChange: (collapsed: boolean) => void;
|
||||||
onClose: () => void;
|
|
||||||
onToggleSelect: (commentId: string) => void;
|
onToggleSelect: (commentId: string) => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
|
|
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
|
||||||
const selectedCount = visibleSelectedIds.size;
|
const selectedCount = visibleSelectedIds.size;
|
||||||
const allSelected = comments.length > 0 && selectedCount === comments.length;
|
const allSelected = comments.length > 0 && selectedCount === comments.length;
|
||||||
const commentsLabel = t('chat.tabComments');
|
const commentsLabel = t('chat.tabComments');
|
||||||
|
const collapsedRailRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const expandedToggleRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const pendingToggleFocusRef = useRef<'collapsed' | 'expanded' | null>(null);
|
||||||
|
const panelId = useId();
|
||||||
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
|
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
|
||||||
const submitNewComment = async () => {
|
const submitNewComment = async () => {
|
||||||
if (!onCreateComment || !newCommentDraft.trim()) return;
|
if (!onCreateComment || !newCommentDraft.trim()) return;
|
||||||
const saved = await onCreateComment(newCommentDraft.trim());
|
const saved = await onCreateComment(newCommentDraft.trim());
|
||||||
if (saved) setNewCommentDraft('');
|
if (saved) setNewCommentDraft('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target =
|
||||||
|
pendingToggleFocusRef.current === 'collapsed'
|
||||||
|
? collapsedRailRef.current
|
||||||
|
: pendingToggleFocusRef.current === 'expanded'
|
||||||
|
? expandedToggleRef.current
|
||||||
|
: null;
|
||||||
|
if (!target) return;
|
||||||
|
pendingToggleFocusRef.current = null;
|
||||||
|
target.focus();
|
||||||
|
}, [collapsed]);
|
||||||
|
|
||||||
|
const handleCollapsedChange = (
|
||||||
|
nextCollapsed: boolean,
|
||||||
|
nextFocusTarget: 'collapsed' | 'expanded',
|
||||||
|
) => {
|
||||||
|
pendingToggleFocusRef.current = nextFocusTarget;
|
||||||
|
onCollapsedChange(nextCollapsed);
|
||||||
|
};
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={collapsedRailRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="comment-side-rail"
|
className="comment-side-rail"
|
||||||
data-testid="comment-side-collapsed-rail"
|
data-testid="comment-side-collapsed-rail"
|
||||||
aria-label={t('preview.showSidebar', { label: commentsLabel })}
|
aria-label={t('preview.showSidebar', { label: commentsLabel })}
|
||||||
|
aria-expanded={false}
|
||||||
title={t('preview.showSidebar', { label: commentsLabel })}
|
title={t('preview.showSidebar', { label: commentsLabel })}
|
||||||
onClick={() => onCollapsedChange(false)}
|
onClick={() => handleCollapsedChange(false, 'expanded')}
|
||||||
>
|
>
|
||||||
<RemixIcon name="message-3-line" size={15} />
|
<RemixIcon name="message-3-line" size={15} />
|
||||||
<span>{commentsLabel}</span>
|
<span>{commentsLabel}</span>
|
||||||
|
|
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
<aside id={panelId} className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
||||||
<div className="comment-side-header">
|
<div className="comment-side-header">
|
||||||
<div className="comment-side-title">
|
<div className="comment-side-title">
|
||||||
<RemixIcon name="message-3-line" size={15} />
|
<RemixIcon name="message-3-line" size={15} />
|
||||||
|
|
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
|
||||||
{t('chat.comments.selectAll')}
|
{t('chat.comments.selectAll')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
ref={expandedToggleRef}
|
||||||
className="comment-side-close"
|
type="button"
|
||||||
aria-label={t('common.close')}
|
className="comment-side-collapse"
|
||||||
title={t('common.close')}
|
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
|
||||||
onClick={onClose}
|
aria-controls={panelId}
|
||||||
>
|
aria-expanded={true}
|
||||||
<Icon name="close" size={12} />
|
title={t('preview.hideSidebar', { label: commentsLabel })}
|
||||||
</button>
|
onClick={() => handleCollapsedChange(true, 'collapsed')}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="comment-side-list">
|
<div className="comment-side-list">
|
||||||
|
|
@ -2299,6 +2387,62 @@ export function CommentSidePanel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CommentSideDock({
|
||||||
|
comments,
|
||||||
|
selectedIds,
|
||||||
|
activeCommentId,
|
||||||
|
collapsed,
|
||||||
|
onCollapsedChange,
|
||||||
|
onToggleSelect,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelection,
|
||||||
|
onReply,
|
||||||
|
onSendSelected,
|
||||||
|
onCreateComment,
|
||||||
|
sending,
|
||||||
|
t,
|
||||||
|
composer,
|
||||||
|
}: {
|
||||||
|
comments: PreviewComment[];
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
activeCommentId: string | null;
|
||||||
|
collapsed: boolean;
|
||||||
|
onCollapsedChange: (collapsed: boolean) => void;
|
||||||
|
onToggleSelect: (commentId: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onReply: (comment: PreviewComment) => void;
|
||||||
|
onSendSelected: () => void | Promise<void>;
|
||||||
|
onCreateComment?: (note: string) => boolean | Promise<boolean>;
|
||||||
|
sending: boolean;
|
||||||
|
t: TranslateFn;
|
||||||
|
composer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`comment-side-dock${collapsed ? ' collapsed' : ''}`}
|
||||||
|
data-testid="comment-side-dock"
|
||||||
|
>
|
||||||
|
<CommentSidePanel
|
||||||
|
comments={comments}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
activeCommentId={activeCommentId}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onCollapsedChange={onCollapsedChange}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
onSelectAll={onSelectAll}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
onReply={onReply}
|
||||||
|
onSendSelected={onSendSelected}
|
||||||
|
onCreateComment={onCreateComment}
|
||||||
|
sending={sending}
|
||||||
|
t={t}
|
||||||
|
composer={composer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
|
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
|
||||||
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
|
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
|
||||||
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
|
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
|
||||||
|
|
@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||||||
const previewStateKey = `${projectId}:${file.name}`;
|
const previewStateKey = `${projectId}:${file.name}`;
|
||||||
const previewScale = zoom / 100;
|
const previewScale = zoom / 100;
|
||||||
|
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
|
||||||
|
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
|
||||||
|
boardMode: localCommentSideDockActive,
|
||||||
|
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||||
|
viewport: previewViewport,
|
||||||
|
});
|
||||||
|
const boardSideDockStacked = usesStackedCommentSideDock(previewBodySize, {
|
||||||
|
boardMode: localCommentSideDockActive,
|
||||||
|
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||||
|
viewport: previewViewport,
|
||||||
|
});
|
||||||
|
|
||||||
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
|
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
|
||||||
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
|
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
|
||||||
|
|
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
const [slideState, setSlideState] = useState<SlideState | null>(
|
const [slideState, setSlideState] = useState<SlideState | null>(
|
||||||
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
|
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
|
||||||
);
|
);
|
||||||
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize);
|
const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
|
||||||
const overlayPreviewScale = overlayPreviewTransform.scale;
|
const overlayPreviewScale = effectivePreviewScale(
|
||||||
|
previewViewport,
|
||||||
|
previewScale,
|
||||||
|
boardPreviewCanvasSize,
|
||||||
|
boardPreviewScaleOptions,
|
||||||
|
);
|
||||||
|
const overlayPreviewTransform: PreviewOverlayTransform = {
|
||||||
|
scale: overlayPreviewScale,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
};
|
||||||
const shareRef = useRef<HTMLDivElement | null>(null);
|
const shareRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
};
|
};
|
||||||
const boardAvailable = mode === 'preview' && source !== null;
|
const boardAvailable = mode === 'preview' && source !== null;
|
||||||
const showPreviewToolbarControls = mode === 'preview';
|
const showPreviewToolbarControls = mode === 'preview';
|
||||||
|
const commentPreviewLayoutClass = [
|
||||||
|
'comment-preview-layer',
|
||||||
|
localCommentSideDockActive ? 'comment-preview-layer-with-side-dock' : '',
|
||||||
|
localCommentSideDockActive && commentSidePanelCollapsed ? 'comment-preview-layer-dock-collapsed' : '',
|
||||||
|
boardSideDockStacked ? 'comment-preview-layer-side-dock-stacked' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
const manualEditPanel = manualEditMode ? (
|
const manualEditPanel = manualEditMode ? (
|
||||||
<ManualEditPanel
|
<ManualEditPanel
|
||||||
targets={manualEditTargets}
|
targets={manualEditTargets}
|
||||||
|
|
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
const commentSidePanel = commentPanelOpen ? (
|
const commentSidePanel = commentPanelOpen ? (
|
||||||
<CommentSidePanel
|
<CommentSideDock
|
||||||
comments={visibleSideComments}
|
comments={visibleSideComments}
|
||||||
selectedIds={selectedSideCommentIds}
|
selectedIds={selectedSideCommentIds}
|
||||||
activeCommentId={activeSideCommentId}
|
activeCommentId={activeSideCommentId}
|
||||||
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
|
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
|
||||||
onCollapsedChange={setCommentSidePanelCollapsed}
|
onCollapsedChange={setCommentSidePanelCollapsed}
|
||||||
onClose={() => {
|
|
||||||
setCommentPanelOpen(false);
|
|
||||||
setCommentSidePanelCollapsed(false);
|
|
||||||
setCommentCreateMode(false);
|
|
||||||
setBoardMode(false);
|
|
||||||
clearBoardComposer();
|
|
||||||
}}
|
|
||||||
onToggleSelect={(commentId) => {
|
onToggleSelect={(commentId) => {
|
||||||
setSelectedSideCommentIds((current) => {
|
setSelectedSideCommentIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
|
|
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||||
) : mode === 'preview' ? (
|
) : mode === 'preview' ? (
|
||||||
<div
|
<div
|
||||||
className={manualEditMode
|
className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
|
||||||
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}`
|
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
|
||||||
: [
|
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
|
||||||
'comment-preview-layer',
|
|
||||||
`preview-viewport preview-viewport-${previewViewport}`,
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
|
|
||||||
>
|
>
|
||||||
{manualEditPanel}
|
{manualEditPanel}
|
||||||
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
|
<div
|
||||||
<div
|
className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
|
||||||
style={
|
data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
|
||||||
manualEditMode
|
>
|
||||||
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
<div className={manualEditMode ? undefined : 'comment-frame-clip'}>
|
||||||
: previewScaleShellStyle(previewViewport, previewScale)
|
<div
|
||||||
}
|
style={
|
||||||
>
|
manualEditMode
|
||||||
<PreviewDrawOverlay
|
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
||||||
active={drawOverlayOpen}
|
: previewScaleShellStyle(previewViewport, previewScale)
|
||||||
onActiveChange={setDrawOverlayOpen}
|
}
|
||||||
captureTarget={null}
|
|
||||||
filePath={file.name}
|
|
||||||
sendDisabled={streaming}
|
|
||||||
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
|
||||||
>
|
>
|
||||||
<div className="artifact-preview-transport-stack">
|
<PreviewDrawOverlay
|
||||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
active={drawOverlayOpen}
|
||||||
<PooledIframe
|
onActiveChange={setDrawOverlayOpen}
|
||||||
ref={urlPreviewIframeRef}
|
captureTarget={null}
|
||||||
cacheKey={urlPreviewKeepAliveKey}
|
filePath={file.name}
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
sendDisabled={streaming}
|
||||||
data-od-render-mode="url-load"
|
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
||||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
>
|
||||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
<div className="artifact-preview-transport-stack">
|
||||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||||
title={file.name}
|
<PooledIframe
|
||||||
sandbox="allow-scripts allow-downloads"
|
ref={urlPreviewIframeRef}
|
||||||
src={urlTransportSrc}
|
cacheKey={urlPreviewKeepAliveKey}
|
||||||
onLoad={() => {
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||||
const frame = urlPreviewIframeRef.current;
|
data-od-render-mode="url-load"
|
||||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||||
dcViewportRestoreAtRef.current = Date.now();
|
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||||
frame?.contentWindow?.postMessage({
|
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||||
type: '__dc_set_viewport',
|
title={file.name}
|
||||||
...dcViewportRef.current,
|
sandbox="allow-scripts allow-downloads"
|
||||||
}, '*');
|
src={urlTransportSrc}
|
||||||
syncBridgeModes(frame);
|
onLoad={() => {
|
||||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
const frame = urlPreviewIframeRef.current;
|
||||||
}}
|
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||||
/>
|
dcViewportRestoreAtRef.current = Date.now();
|
||||||
) : (
|
frame?.contentWindow?.postMessage({
|
||||||
|
type: '__dc_set_viewport',
|
||||||
|
...dcViewportRef.current,
|
||||||
|
}, '*');
|
||||||
|
syncBridgeModes(frame);
|
||||||
|
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
ref={urlPreviewIframeRef}
|
||||||
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||||
|
data-od-render-mode="url-load"
|
||||||
|
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||||
|
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||||
|
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||||
|
title={file.name}
|
||||||
|
sandbox="allow-scripts allow-downloads"
|
||||||
|
src={urlTransportSrc}
|
||||||
|
onLoad={() => {
|
||||||
|
const frame = urlPreviewIframeRef.current;
|
||||||
|
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||||
|
dcViewportRestoreAtRef.current = Date.now();
|
||||||
|
frame?.contentWindow?.postMessage({
|
||||||
|
type: '__dc_set_viewport',
|
||||||
|
...dcViewportRef.current,
|
||||||
|
}, '*');
|
||||||
|
syncBridgeModes(frame);
|
||||||
|
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<iframe
|
<iframe
|
||||||
ref={urlPreviewIframeRef}
|
key={srcDocTransportResetKey}
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
ref={srcDocPreviewIframeRef}
|
||||||
data-od-render-mode="url-load"
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
data-od-render-mode="srcdoc"
|
||||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
aria-hidden={useUrlLoadPreview ? true : undefined}
|
||||||
|
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||||
title={file.name}
|
title={file.name}
|
||||||
sandbox="allow-scripts allow-downloads"
|
sandbox="allow-scripts allow-downloads"
|
||||||
src={urlTransportSrc}
|
srcDoc={srcDocTransportContent}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
const frame = urlPreviewIframeRef.current;
|
const frame = srcDocPreviewIframeRef.current;
|
||||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||||
|
// Reset the activation dedupe exactly ONCE per
|
||||||
|
// freshly mounted iframe DOM node, never on the
|
||||||
|
// subsequent load events that the same node
|
||||||
|
// emits during normal srcDoc rendering.
|
||||||
|
//
|
||||||
|
// The iframe's load event fires twice for one
|
||||||
|
// successful activation: once when the lazy
|
||||||
|
// transport shell HTML loads, and again when
|
||||||
|
// our own document.open/write/close inside the
|
||||||
|
// shell finishes. PR #2699 reset the dedupe on
|
||||||
|
// every load so that switching
|
||||||
|
// preview -> source -> preview (which remounts
|
||||||
|
// this iframe as a fresh DOM node) would
|
||||||
|
// re-activate the new shell. But resetting on
|
||||||
|
// every load also re-activated on the SECOND
|
||||||
|
// load of a non-remounted frame, which
|
||||||
|
// re-triggered document.open/write/close, which
|
||||||
|
// re-fired the load event, ad infinitum. The
|
||||||
|
// dedupe ref oscillated between null and the
|
||||||
|
// current srcDoc thousands of times per render
|
||||||
|
// and each iteration restarted every CSS
|
||||||
|
// animation from its `from` keyframe. Designs
|
||||||
|
// using `animation-fill-mode: both` with
|
||||||
|
// `from { opacity: 0 }` stayed at opacity 0
|
||||||
|
// forever and the preview read as blank.
|
||||||
|
// That is issue #2361.
|
||||||
|
//
|
||||||
|
// Tracking the last frame we reset for lets us
|
||||||
|
// keep PR #2699's "remount after Source toggle"
|
||||||
|
// fix while breaking the loop on plain renders.
|
||||||
|
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||||
|
srcDocFrameDedupeResetForRef.current = frame;
|
||||||
|
activatedSrcDocTransportHtmlRef.current = null;
|
||||||
|
}
|
||||||
|
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||||
|
activateLoadedSrcDocTransport(frame);
|
||||||
dcViewportRestoreAtRef.current = Date.now();
|
dcViewportRestoreAtRef.current = Date.now();
|
||||||
frame?.contentWindow?.postMessage({
|
frame?.contentWindow?.postMessage({
|
||||||
type: '__dc_set_viewport',
|
type: '__dc_set_viewport',
|
||||||
...dcViewportRef.current,
|
...dcViewportRef.current,
|
||||||
}, '*');
|
}, '*');
|
||||||
|
replayInspectOverridesToIframe(frame);
|
||||||
syncBridgeModes(frame);
|
syncBridgeModes(frame);
|
||||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<iframe
|
</PreviewDrawOverlay>
|
||||||
key={srcDocTransportResetKey}
|
</div>
|
||||||
ref={srcDocPreviewIframeRef}
|
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
|
||||||
data-od-render-mode="srcdoc"
|
|
||||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
|
||||||
aria-hidden={useUrlLoadPreview ? true : undefined}
|
|
||||||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
|
||||||
title={file.name}
|
|
||||||
sandbox="allow-scripts allow-downloads"
|
|
||||||
srcDoc={srcDocTransportContent}
|
|
||||||
onLoad={() => {
|
|
||||||
const frame = srcDocPreviewIframeRef.current;
|
|
||||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
|
||||||
// Reset the activation dedupe exactly ONCE per
|
|
||||||
// freshly mounted iframe DOM node, never on the
|
|
||||||
// subsequent load events that the same node
|
|
||||||
// emits during normal srcDoc rendering.
|
|
||||||
//
|
|
||||||
// The iframe's load event fires twice for one
|
|
||||||
// successful activation: once when the lazy
|
|
||||||
// transport shell HTML loads, and again when
|
|
||||||
// our own document.open/write/close inside the
|
|
||||||
// shell finishes. PR #2699 reset the dedupe on
|
|
||||||
// every load so that switching
|
|
||||||
// preview -> source -> preview (which remounts
|
|
||||||
// this iframe as a fresh DOM node) would
|
|
||||||
// re-activate the new shell. But resetting on
|
|
||||||
// every load also re-activated on the SECOND
|
|
||||||
// load of a non-remounted frame, which
|
|
||||||
// re-triggered document.open/write/close, which
|
|
||||||
// re-fired the load event, ad infinitum. The
|
|
||||||
// dedupe ref oscillated between null and the
|
|
||||||
// current srcDoc thousands of times per render
|
|
||||||
// and each iteration restarted every CSS
|
|
||||||
// animation from its `from` keyframe. Designs
|
|
||||||
// using `animation-fill-mode: both` with
|
|
||||||
// `from { opacity: 0 }` stayed at opacity 0
|
|
||||||
// forever and the preview read as blank.
|
|
||||||
// That is issue #2361.
|
|
||||||
//
|
|
||||||
// Tracking the last frame we reset for lets us
|
|
||||||
// keep PR #2699's "remount after Source toggle"
|
|
||||||
// fix while breaking the loop on plain renders.
|
|
||||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
|
||||||
srcDocFrameDedupeResetForRef.current = frame;
|
|
||||||
activatedSrcDocTransportHtmlRef.current = null;
|
|
||||||
}
|
|
||||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
|
||||||
activateLoadedSrcDocTransport(frame);
|
|
||||||
dcViewportRestoreAtRef.current = Date.now();
|
|
||||||
frame?.contentWindow?.postMessage({
|
|
||||||
type: '__dc_set_viewport',
|
|
||||||
...dcViewportRef.current,
|
|
||||||
}, '*');
|
|
||||||
replayInspectOverridesToIframe(frame);
|
|
||||||
syncBridgeModes(frame);
|
|
||||||
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PreviewDrawOverlay>
|
|
||||||
</div>
|
</div>
|
||||||
|
{boardMode ? (
|
||||||
|
<CommentPreviewOverlays
|
||||||
|
comments={commentCreateMode ? visibleSideComments : []}
|
||||||
|
liveTargets={liveCommentTargets}
|
||||||
|
hoveredTarget={hoveredCommentTarget}
|
||||||
|
hoveredPodMemberId={hoveredPodMemberId}
|
||||||
|
activeTarget={activeCommentTarget}
|
||||||
|
boardTool={boardTool}
|
||||||
|
showActivePin={commentCreateMode}
|
||||||
|
scale={overlayPreviewScale}
|
||||||
|
offsetX={overlayPreviewTransform.offsetX}
|
||||||
|
offsetY={overlayPreviewTransform.offsetY}
|
||||||
|
strokePoints={strokePoints}
|
||||||
|
onOpenComment={(comment, snapshot) => {
|
||||||
|
setCommentPanelOpen(true);
|
||||||
|
setCommentSidePanelCollapsed(false);
|
||||||
|
setCommentCreateMode(true);
|
||||||
|
setBoardMode(true);
|
||||||
|
setActiveCommentTarget(snapshot);
|
||||||
|
setHoveredCommentTarget(snapshot);
|
||||||
|
setActivePreviewCommentId(comment.id);
|
||||||
|
setCommentDraft(comment.note);
|
||||||
|
setQueuedBoardNotes([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{exportToast ? (
|
||||||
|
<div className="comment-toast-anchor">
|
||||||
|
<Toast
|
||||||
|
message={exportToast}
|
||||||
|
ttlMs={2200}
|
||||||
|
onDismiss={() => setExportToast(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{commentSavedToast ? (
|
||||||
|
<div className="comment-toast-anchor">
|
||||||
|
<Toast
|
||||||
|
message={commentSavedToast}
|
||||||
|
ttlMs={2200}
|
||||||
|
onDismiss={() => setCommentSavedToast(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{templateSavedToast ? (
|
||||||
|
<div className="comment-toast-anchor">
|
||||||
|
<Toast
|
||||||
|
message={templateSavedToast}
|
||||||
|
ttlMs={2200}
|
||||||
|
onDismiss={() => setTemplateSavedToast(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{commentComposer}
|
||||||
|
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
||||||
|
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
||||||
|
) : null}
|
||||||
|
{/*
|
||||||
|
Hint banner for Inspect / Picker modes. The bridge in
|
||||||
|
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
||||||
|
with every element annotated with `data-od-id` /
|
||||||
|
`data-screen-label`, so `liveCommentTargets.size` is the
|
||||||
|
authoritative annotation count for the current artifact.
|
||||||
|
|
||||||
|
Two states:
|
||||||
|
- "has targets": the existing copy ("Click any element with
|
||||||
|
`data-od-id` to tune its style.") for users who just don't
|
||||||
|
see the crosshair cursor.
|
||||||
|
- "no targets" (issue #890): a freeform-generated artifact
|
||||||
|
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
||||||
|
without a skill) ships zero `data-od-id` annotations. The
|
||||||
|
bridge's click handler walks up to <html>, finds nothing,
|
||||||
|
and bails — clicks no-op silently. The static copy made
|
||||||
|
this look broken; the empty-state copy explains what's
|
||||||
|
missing and how to fix it. Mirrored across Inspect and
|
||||||
|
element-pick annotation mode because the failure surface is identical.
|
||||||
|
*/}
|
||||||
|
{inspectMode
|
||||||
|
&& openHintBox
|
||||||
|
&& !activeInspectTarget
|
||||||
|
&& !activeCommentTarget ? (
|
||||||
|
<div
|
||||||
|
className="inspect-empty-hint-container"
|
||||||
|
data-testid="inspect-empty-hint-container"
|
||||||
|
>
|
||||||
|
{liveCommentTargets.size === 0 ? (
|
||||||
|
<div
|
||||||
|
className="inspect-empty-hint"
|
||||||
|
data-testid="inspect-empty-hint-no-targets"
|
||||||
|
>
|
||||||
|
{inspectMode
|
||||||
|
? t('chat.inspect.noEditableTargets')
|
||||||
|
: t('chat.inspect.noCommentTargets')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="inspect-empty-hint"
|
||||||
|
data-testid="inspect-empty-hint"
|
||||||
|
>
|
||||||
|
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Close Inspect Hint"
|
||||||
|
aria-label="Close Inspect Hint"
|
||||||
|
onClick={() => setOpenHintBox(false)}
|
||||||
|
className="orbit-artifact-ghost"
|
||||||
|
>
|
||||||
|
<Icon className="" name="close" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{boardMode ? (
|
|
||||||
<CommentPreviewOverlays
|
|
||||||
comments={commentCreateMode ? visibleSideComments : []}
|
|
||||||
liveTargets={liveCommentTargets}
|
|
||||||
hoveredTarget={hoveredCommentTarget}
|
|
||||||
hoveredPodMemberId={hoveredPodMemberId}
|
|
||||||
activeTarget={activeCommentTarget}
|
|
||||||
boardTool={boardTool}
|
|
||||||
showActivePin={commentCreateMode}
|
|
||||||
scale={overlayPreviewScale}
|
|
||||||
offsetX={overlayPreviewTransform.offsetX}
|
|
||||||
offsetY={overlayPreviewTransform.offsetY}
|
|
||||||
strokePoints={strokePoints}
|
|
||||||
onOpenComment={(comment, snapshot) => {
|
|
||||||
setCommentPanelOpen(true);
|
|
||||||
setCommentSidePanelCollapsed(false);
|
|
||||||
setCommentCreateMode(true);
|
|
||||||
setBoardMode(true);
|
|
||||||
setActiveCommentTarget(snapshot);
|
|
||||||
setHoveredCommentTarget(snapshot);
|
|
||||||
setActivePreviewCommentId(comment.id);
|
|
||||||
setCommentDraft(comment.note);
|
|
||||||
setQueuedBoardNotes([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{exportToast ? (
|
|
||||||
<div className="comment-toast-anchor">
|
|
||||||
<Toast
|
|
||||||
message={exportToast}
|
|
||||||
ttlMs={2200}
|
|
||||||
onDismiss={() => setExportToast(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{commentSavedToast ? (
|
|
||||||
<div className="comment-toast-anchor">
|
|
||||||
<Toast
|
|
||||||
message={commentSavedToast}
|
|
||||||
ttlMs={2200}
|
|
||||||
onDismiss={() => setCommentSavedToast(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{templateSavedToast ? (
|
|
||||||
<div className="comment-toast-anchor">
|
|
||||||
<Toast
|
|
||||||
message={templateSavedToast}
|
|
||||||
ttlMs={2200}
|
|
||||||
onDismiss={() => setTemplateSavedToast(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{commentComposer}
|
|
||||||
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
|
||||||
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
|
||||||
) : null}
|
|
||||||
{commentPortalHost && commentSidePanel
|
{commentPortalHost && commentSidePanel
|
||||||
? createPortal(commentSidePanel, commentPortalHost)
|
? createPortal(commentSidePanel, commentPortalHost)
|
||||||
: commentPortalId
|
: commentPortalId
|
||||||
|
|
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
error={inspectError}
|
error={inspectError}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/*
|
|
||||||
Hint banner for Inspect / Picker modes. The bridge in
|
|
||||||
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
|
||||||
with every element annotated with `data-od-id` /
|
|
||||||
`data-screen-label`, so `liveCommentTargets.size` is the
|
|
||||||
authoritative annotation count for the current artifact.
|
|
||||||
|
|
||||||
Two states:
|
|
||||||
- "has targets": the existing copy ("Click any element with
|
|
||||||
`data-od-id` to tune its style.") for users who just don't
|
|
||||||
see the crosshair cursor.
|
|
||||||
- "no targets" (issue #890): a freeform-generated artifact
|
|
||||||
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
|
||||||
without a skill) ships zero `data-od-id` annotations. The
|
|
||||||
bridge's click handler walks up to <html>, finds nothing,
|
|
||||||
and bails — clicks no-op silently. The static copy made
|
|
||||||
this look broken; the empty-state copy explains what's
|
|
||||||
missing and how to fix it. Mirrored across Inspect and
|
|
||||||
element-pick annotation mode because the failure surface is identical.
|
|
||||||
*/}
|
|
||||||
{inspectMode
|
|
||||||
&& openHintBox
|
|
||||||
&& !activeInspectTarget
|
|
||||||
&& !activeCommentTarget ? (
|
|
||||||
<div
|
|
||||||
className={`inspect-empty-hint-container${
|
|
||||||
commentPanelOpen && !commentSidePanelCollapsed ? ' comment-side-panel-open' : ''
|
|
||||||
}`}
|
|
||||||
data-testid="inspect-empty-hint-container"
|
|
||||||
>
|
|
||||||
{liveCommentTargets.size === 0 ? (
|
|
||||||
<div
|
|
||||||
className="inspect-empty-hint"
|
|
||||||
data-testid="inspect-empty-hint-no-targets"
|
|
||||||
>
|
|
||||||
{inspectMode
|
|
||||||
? t('chat.inspect.noEditableTargets')
|
|
||||||
: t('chat.inspect.noCommentTargets')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="inspect-empty-hint"
|
|
||||||
data-testid="inspect-empty-hint"
|
|
||||||
>
|
|
||||||
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Close Inspect Hint"
|
|
||||||
aria-label="Close Inspect Hint"
|
|
||||||
onClick={() => setOpenHintBox(false)}
|
|
||||||
className="orbit-artifact-ghost"
|
|
||||||
>
|
|
||||||
<Icon className="" name="close" size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="viewer-source">{source}</pre>
|
<pre className="viewer-source">{source}</pre>
|
||||||
|
|
|
||||||
|
|
@ -847,13 +847,15 @@ export function NewProjectPanel({
|
||||||
) : null}
|
) : null}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<input
|
<div className="newproj-name-row">
|
||||||
className="newproj-name"
|
<input
|
||||||
data-testid="new-project-name"
|
className="newproj-name"
|
||||||
placeholder={t('newproj.namePlaceholder')}
|
data-testid="new-project-name"
|
||||||
value={name}
|
placeholder={t('newproj.namePlaceholder')}
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
/>
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showDesignSystemPicker ? (
|
{showDesignSystemPicker ? (
|
||||||
<DesignSystemPicker
|
<DesignSystemPicker
|
||||||
|
|
|
||||||
239
apps/web/src/components/ProjectLocationsSection.tsx
Normal file
239
apps/web/src/components/ProjectLocationsSection.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import type { ProjectLocation } from '@open-design/contracts';
|
||||||
|
import type { AppConfig } from '../types';
|
||||||
|
import {
|
||||||
|
fetchProjectLocations,
|
||||||
|
openProjectLocationFolderDialog,
|
||||||
|
scanProjectLocations,
|
||||||
|
updateProjectLocations,
|
||||||
|
} from '../state/project-locations';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cfg: AppConfig;
|
||||||
|
setCfg: Dispatch<SetStateAction<AppConfig>>;
|
||||||
|
onProjectsRefresh?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftLocation {
|
||||||
|
id?: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(locationPath: string): string {
|
||||||
|
return locationPath.split(/[\\/]/).filter(Boolean).pop() || locationPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalLocations(locations: ProjectLocation[]): DraftLocation[] {
|
||||||
|
return locations
|
||||||
|
.filter((location) => !location.builtIn)
|
||||||
|
.map((location) => ({ id: location.id, path: location.path }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConfigLocations(locations: ProjectLocation[]): NonNullable<AppConfig['projectLocations']> {
|
||||||
|
return locations
|
||||||
|
.filter((location) => !location.builtIn)
|
||||||
|
.map((location) => ({ id: location.id, name: location.name, path: location.path }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectLocationsSection({ cfg, setCfg, onProjectsRefresh }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [locations, setLocations] = useState<ProjectLocation[]>([]);
|
||||||
|
const [drafts, setDrafts] = useState<DraftLocation[]>(cfg.projectLocations ?? []);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const draftsRef = useRef<DraftLocation[]>(drafts);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftsRef.current = drafts;
|
||||||
|
}, [drafts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
fetchProjectLocations()
|
||||||
|
.then((next) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLocations(next);
|
||||||
|
setDrafts(externalLocations(next));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [setCfg]);
|
||||||
|
|
||||||
|
const builtIn = useMemo(
|
||||||
|
() => locations.find((location) => location.builtIn),
|
||||||
|
[locations],
|
||||||
|
);
|
||||||
|
const effectiveDefaultLocationId = useMemo(() => {
|
||||||
|
const configured = cfg.defaultProjectLocationId ?? 'default';
|
||||||
|
return locations.some((location) => location.id === configured) ? configured : 'default';
|
||||||
|
}, [cfg.defaultProjectLocationId, locations]);
|
||||||
|
|
||||||
|
function defaultControlLabel(locationId: string): string {
|
||||||
|
return effectiveDefaultLocationId === locationId
|
||||||
|
? t('settings.projectLocationsDefaultBadge')
|
||||||
|
: t('settings.projectLocationsMakeDefault');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDefaultLocationChange(locationId: string) {
|
||||||
|
setError(null);
|
||||||
|
setStatus(t('settings.projectLocationsDefaultSaved'));
|
||||||
|
setCfg((current) => ({ ...current, defaultProjectLocationId: locationId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(nextDrafts: DraftLocation[]) {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
const saved = await updateProjectLocations(
|
||||||
|
nextDrafts.filter((location) => location.path.trim()),
|
||||||
|
);
|
||||||
|
if (!saved) {
|
||||||
|
setError(t('settings.projectLocationsSaveError'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setLocations(saved);
|
||||||
|
const external = externalLocations(saved);
|
||||||
|
setDrafts(external);
|
||||||
|
setCfg((current) => {
|
||||||
|
const configuredDefault = current.defaultProjectLocationId ?? 'default';
|
||||||
|
const nextDefault = saved.some((location) => location.id === configuredDefault)
|
||||||
|
? configuredDefault
|
||||||
|
: 'default';
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
projectLocations: toConfigLocations(saved),
|
||||||
|
defaultProjectLocationId: nextDefault,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setStatus(t('settings.projectLocationsSaved'));
|
||||||
|
void onProjectsRefresh?.();
|
||||||
|
return external;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScan() {
|
||||||
|
const result = await scanProjectLocations();
|
||||||
|
if (!result) {
|
||||||
|
setError(t('settings.projectLocationsScanError'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setStatus(t('settings.projectLocationsScanComplete', {
|
||||||
|
imported: result.imported.length,
|
||||||
|
existing: result.existing.length,
|
||||||
|
}));
|
||||||
|
void onProjectsRefresh?.();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddFolder() {
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
const selected = await openProjectLocationFolderDialog();
|
||||||
|
if (!selected) {
|
||||||
|
setStatus(t('settings.projectLocationsNoFolderSelected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draftsRef.current.some((draft) => draft.path === selected)) {
|
||||||
|
setStatus(t('settings.projectLocationsDuplicate'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = draftsRef.current;
|
||||||
|
const next = [...previous, { path: selected }];
|
||||||
|
setDrafts(next);
|
||||||
|
const saved = await save(next);
|
||||||
|
if (!saved) setDrafts(previous);
|
||||||
|
else await runScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDraft(index: number) {
|
||||||
|
const previous = draftsRef.current;
|
||||||
|
const next = previous.filter((_, i) => i !== index);
|
||||||
|
setDrafts(next);
|
||||||
|
const saved = await save(next);
|
||||||
|
if (!saved) setDrafts(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section settings-section-card project-locations-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>{t('settings.projectLocations')}</h3>
|
||||||
|
<p className="hint">{t('settings.projectLocationsDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{builtIn ? (
|
||||||
|
<div className={`project-location-card is-built-in${effectiveDefaultLocationId === builtIn.id ? ' is-default' : ''}`}>
|
||||||
|
<div>
|
||||||
|
<strong>{t('newproj.locationDefault')}</strong>
|
||||||
|
<code>{builtIn.path}</code>
|
||||||
|
</div>
|
||||||
|
<label className="project-location-default-control">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="project-location-default"
|
||||||
|
checked={effectiveDefaultLocationId === builtIn.id}
|
||||||
|
onChange={() => handleDefaultLocationChange(builtIn.id)}
|
||||||
|
/>
|
||||||
|
<span>{defaultControlLabel(builtIn.id)}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="project-location-list">
|
||||||
|
{drafts.map((draft, index) => (
|
||||||
|
<div
|
||||||
|
className={`project-location-edit${draft.id && effectiveDefaultLocationId === draft.id ? ' is-default' : ''}`}
|
||||||
|
key={`${draft.id ?? 'new'}-${index}`}
|
||||||
|
>
|
||||||
|
<div className="project-location-edit-main">
|
||||||
|
<strong>{locationLabel(draft.path)}</strong>
|
||||||
|
<code>{draft.path}</code>
|
||||||
|
<small>{t('settings.projectLocationsWorkBaseMeta')}</small>
|
||||||
|
</div>
|
||||||
|
{draft.id ? (
|
||||||
|
<label className="project-location-default-control">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="project-location-default"
|
||||||
|
checked={effectiveDefaultLocationId === draft.id}
|
||||||
|
onChange={() => handleDefaultLocationChange(draft.id!)}
|
||||||
|
/>
|
||||||
|
<span>{defaultControlLabel(draft.id)}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="icon-btn danger" onClick={() => removeDraft(index)} disabled={saving}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn project-location-add"
|
||||||
|
onClick={handleAddFolder}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={12} />
|
||||||
|
{t('settings.projectLocationsAddFolder')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status ? <p className="settings-rescan-status">{status}</p> : null}
|
||||||
|
{error ? <p className="settings-rescan-status error">{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -94,6 +94,7 @@ import { McpClientSection } from './McpClientSection';
|
||||||
import { SkillsSection } from './SkillsSection';
|
import { SkillsSection } from './SkillsSection';
|
||||||
import { DesignSystemsSection } from './DesignSystemsSection';
|
import { DesignSystemsSection } from './DesignSystemsSection';
|
||||||
import { PrivacySection } from './PrivacySection';
|
import { PrivacySection } from './PrivacySection';
|
||||||
|
import { ProjectLocationsSection } from './ProjectLocationsSection';
|
||||||
import { RoutinesSection } from './RoutinesSection';
|
import { RoutinesSection } from './RoutinesSection';
|
||||||
import { ConnectorsBrowser } from './ConnectorsBrowser';
|
import { ConnectorsBrowser } from './ConnectorsBrowser';
|
||||||
import { MemoryModelInline } from './MemoryModelInline';
|
import { MemoryModelInline } from './MemoryModelInline';
|
||||||
|
|
@ -135,6 +136,7 @@ export type SettingsSection =
|
||||||
| 'pet'
|
| 'pet'
|
||||||
| 'skills'
|
| 'skills'
|
||||||
| 'designSystems'
|
| 'designSystems'
|
||||||
|
| 'projectLocations'
|
||||||
| 'memory'
|
| 'memory'
|
||||||
| 'privacy'
|
| 'privacy'
|
||||||
// 'library' is consumed by the EntryShell library route — App opens it
|
// 'library' is consumed by the EntryShell library route — App opens it
|
||||||
|
|
@ -194,6 +196,7 @@ interface Props {
|
||||||
daemonMediaProvidersFetchState?: 'idle' | 'ok' | 'error';
|
daemonMediaProvidersFetchState?: 'idle' | 'ok' | 'error';
|
||||||
mediaProvidersNotice?: string | null;
|
mediaProvidersNotice?: string | null;
|
||||||
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
|
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
|
||||||
|
onProjectsRefresh?: () => Promise<void> | void;
|
||||||
/**
|
/**
|
||||||
* Notified by Settings → Skills after a successful skill registry
|
* Notified by Settings → Skills after a successful skill registry
|
||||||
* mutation (create / edit / delete). App.tsx uses this to drop preview
|
* mutation (create / edit / delete). App.tsx uses this to drop preview
|
||||||
|
|
@ -835,6 +838,7 @@ export function SettingsDialog({
|
||||||
daemonMediaProvidersFetchState = 'idle',
|
daemonMediaProvidersFetchState = 'idle',
|
||||||
mediaProvidersNotice,
|
mediaProvidersNotice,
|
||||||
onReloadMediaProviders,
|
onReloadMediaProviders,
|
||||||
|
onProjectsRefresh,
|
||||||
onSkillsChanged,
|
onSkillsChanged,
|
||||||
onDesignSystemsChanged,
|
onDesignSystemsChanged,
|
||||||
providerModelsCache: sharedProviderModelsCache,
|
providerModelsCache: sharedProviderModelsCache,
|
||||||
|
|
@ -2034,6 +2038,10 @@ export function SettingsDialog({
|
||||||
title: t('settings.designSystems'),
|
title: t('settings.designSystems'),
|
||||||
subtitle: t('settings.designSystemsHint'),
|
subtitle: t('settings.designSystemsHint'),
|
||||||
},
|
},
|
||||||
|
projectLocations: {
|
||||||
|
title: t('settings.projectLocations'),
|
||||||
|
subtitle: t('settings.projectLocationsHint'),
|
||||||
|
},
|
||||||
memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') },
|
memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') },
|
||||||
// 'library' is opened via EntryShell route — SettingsDialog doesn't
|
// 'library' is opened via EntryShell route — SettingsDialog doesn't
|
||||||
// render it but SettingsSection must accept the token (see type def).
|
// render it but SettingsSection must accept the token (see type def).
|
||||||
|
|
@ -2465,6 +2473,17 @@ export function SettingsDialog({
|
||||||
<small>{t('settings.designSystemsHint')}</small>
|
<small>{t('settings.designSystemsHint')}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`settings-nav-item${activeSection === 'projectLocations' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveSection('projectLocations')}
|
||||||
|
>
|
||||||
|
<Icon name="folder" size={18} />
|
||||||
|
<span>
|
||||||
|
<strong>{t('settings.projectLocations')}</strong>
|
||||||
|
<small>{t('settings.projectLocationsHint')}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
|
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
|
||||||
|
|
@ -3664,6 +3683,10 @@ export function SettingsDialog({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeSection === 'projectLocations' ? (
|
||||||
|
<ProjectLocationsSection cfg={cfg} setCfg={setCfg} onProjectsRefresh={onProjectsRefresh} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeSection === 'instructions' ? (
|
{activeSection === 'instructions' ? (
|
||||||
<section className="settings-section settings-section-card instructions-rules-section">
|
<section className="settings-section settings-section-card instructions-rules-section">
|
||||||
<div className="memory-field-block instructions-rules-card">
|
<div className="memory-field-block instructions-rules-card">
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const ar: Dict = {
|
||||||
'newproj.fileSingular': 'ملف',
|
'newproj.fileSingular': 'ملف',
|
||||||
'newproj.filePlural': 'ملفات',
|
'newproj.filePlural': 'ملفات',
|
||||||
'newproj.create': 'إنشاء',
|
'newproj.create': 'إنشاء',
|
||||||
|
'newproj.locationLabel': 'حفظ في',
|
||||||
|
'newproj.locationDefault': 'مشاريع Open Design',
|
||||||
|
'newproj.locationExternalBase': 'قاعدة خارجية',
|
||||||
'newproj.createFromTemplate': 'إنشاء من قالب',
|
'newproj.createFromTemplate': 'إنشاء من قالب',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).',
|
'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).',
|
||||||
|
|
@ -1552,6 +1555,20 @@ export const ar: Dict = {
|
||||||
'settings.designSystemsCategory': 'الفئة',
|
'settings.designSystemsCategory': 'الفئة',
|
||||||
'settings.designSystemsAllCategories': 'كل الفئات',
|
'settings.designSystemsAllCategories': 'كل الفئات',
|
||||||
'settings.designSystemsShowInHomeGallery': 'إظهار في معرض الصفحة الرئيسية',
|
'settings.designSystemsShowInHomeGallery': 'إظهار في معرض الصفحة الرئيسية',
|
||||||
|
'settings.projectLocations': 'مواقع المشاريع',
|
||||||
|
'settings.projectLocationsHint': 'جذور تخزين مساحات العمل',
|
||||||
|
'settings.projectLocationsDescription': 'أضف قواعد عمل يمكن أن تحتوي على عدة مجلدات مشاريع Open Design. تُحفظ المشاريع الجديدة كمجلد داخل القاعدة المحددة.',
|
||||||
|
'settings.projectLocationsSaveError': 'تعذّر حفظ مواقع المشاريع. تحقق من أن كل مسار مجلد يمكن الوصول إليه.',
|
||||||
|
'settings.projectLocationsSaved': 'تم حفظ مواقع المشاريع.',
|
||||||
|
'settings.projectLocationsScanError': 'تعذّر فحص مواقع المشاريع.',
|
||||||
|
'settings.projectLocationsScanComplete': 'اكتمل الفحص: تم استيراد {imported}، و{existing} مسجلة مسبقًا.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'لم يتم اختيار مجلد.',
|
||||||
|
'settings.projectLocationsDuplicate': 'تمت إضافة قاعدة العمل هذه بالفعل.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'قاعدة عمل · يتم إنشاء المشاريع هنا كمجلدات فرعية',
|
||||||
|
'settings.projectLocationsAddFolder': 'إضافة مجلد…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'الموقع الافتراضي',
|
||||||
|
'settings.projectLocationsMakeDefault': 'تعيين كافتراضي',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'تم تحديث موقع المشروع الافتراضي.',
|
||||||
'settings.librarySkills': 'المهارات',
|
'settings.librarySkills': 'المهارات',
|
||||||
'settings.libraryDesignSystems': 'أنظمة التصميم',
|
'settings.libraryDesignSystems': 'أنظمة التصميم',
|
||||||
'settings.librarySearch': 'بحث...',
|
'settings.librarySearch': 'بحث...',
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,9 @@ export const de: Dict = {
|
||||||
'newproj.fileSingular': 'Datei',
|
'newproj.fileSingular': 'Datei',
|
||||||
'newproj.filePlural': 'Dateien',
|
'newproj.filePlural': 'Dateien',
|
||||||
'newproj.create': 'Erstellen',
|
'newproj.create': 'Erstellen',
|
||||||
|
'newproj.locationLabel': 'Speichern unter',
|
||||||
|
'newproj.locationDefault': 'Open Design-Projekte',
|
||||||
|
'newproj.locationExternalBase': 'Externe Basis',
|
||||||
'newproj.createFromTemplate': 'Aus Template erstellen',
|
'newproj.createFromTemplate': 'Aus Template erstellen',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Speichern Sie zuerst ein Projekt als Template (Teilen-Menü in einem beliebigen Projekt).',
|
'Speichern Sie zuerst ein Projekt als Template (Teilen-Menü in einem beliebigen Projekt).',
|
||||||
|
|
@ -1490,6 +1493,20 @@ export const de: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategorie',
|
'settings.designSystemsCategory': 'Kategorie',
|
||||||
'settings.designSystemsAllCategories': 'Alle Kategorien',
|
'settings.designSystemsAllCategories': 'Alle Kategorien',
|
||||||
'settings.designSystemsShowInHomeGallery': 'In Home-Galerie anzeigen',
|
'settings.designSystemsShowInHomeGallery': 'In Home-Galerie anzeigen',
|
||||||
|
'settings.projectLocations': 'Projektorte',
|
||||||
|
'settings.projectLocationsHint': 'Workspace-Speicherorte',
|
||||||
|
'settings.projectLocationsDescription': 'Füge Arbeitsbasen hinzu, die mehrere Open Design-Projektordner enthalten können. Neue Projekte werden als Ordner in der ausgewählten Basis gespeichert.',
|
||||||
|
'settings.projectLocationsSaveError': 'Projektorte konnten nicht gespeichert werden. Prüfe, ob jeder Pfad ein zugänglicher Ordner ist.',
|
||||||
|
'settings.projectLocationsSaved': 'Projektorte gespeichert.',
|
||||||
|
'settings.projectLocationsScanError': 'Projektorte konnten nicht gescannt werden.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Scan abgeschlossen: {imported} importiert, {existing} bereits registriert.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Kein Ordner ausgewählt.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Diese Arbeitsbasis wurde bereits hinzugefügt.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Arbeitsbasis · Projekte werden hier als Unterordner erstellt',
|
||||||
|
'settings.projectLocationsAddFolder': 'Ordner hinzufügen…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Standardort',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Als Standard festlegen',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Standard-Projektort aktualisiert.',
|
||||||
'settings.librarySkills': 'Fähigkeiten',
|
'settings.librarySkills': 'Fähigkeiten',
|
||||||
'settings.libraryDesignSystems': 'Designsysteme',
|
'settings.libraryDesignSystems': 'Designsysteme',
|
||||||
'settings.librarySearch': 'Suchen...',
|
'settings.librarySearch': 'Suchen...',
|
||||||
|
|
|
||||||
|
|
@ -1157,6 +1157,9 @@ export const en: Dict = {
|
||||||
'newproj.fileSingular': 'file',
|
'newproj.fileSingular': 'file',
|
||||||
'newproj.filePlural': 'files',
|
'newproj.filePlural': 'files',
|
||||||
'newproj.create': 'Create',
|
'newproj.create': 'Create',
|
||||||
|
'newproj.locationLabel': 'Save to',
|
||||||
|
'newproj.locationDefault': 'Open Design projects',
|
||||||
|
'newproj.locationExternalBase': 'External base',
|
||||||
'newproj.createLiveArtifact': 'Create live artifact',
|
'newproj.createLiveArtifact': 'Create live artifact',
|
||||||
'newproj.createFromTemplate': 'Create from template',
|
'newproj.createFromTemplate': 'Create from template',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -2349,6 +2352,20 @@ export const en: Dict = {
|
||||||
'settings.designSystemsCategory': 'Category',
|
'settings.designSystemsCategory': 'Category',
|
||||||
'settings.designSystemsAllCategories': 'All categories',
|
'settings.designSystemsAllCategories': 'All categories',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Show in home gallery',
|
'settings.designSystemsShowInHomeGallery': 'Show in home gallery',
|
||||||
|
'settings.projectLocations': 'Project locations',
|
||||||
|
'settings.projectLocationsHint': 'Workspace storage roots',
|
||||||
|
'settings.projectLocationsDescription': 'Add work bases that can contain multiple Open Design project folders. New projects are saved as one folder inside the selected base.',
|
||||||
|
'settings.projectLocationsSaveError': 'Could not save project locations. Check that each path is an accessible folder.',
|
||||||
|
'settings.projectLocationsSaved': 'Project locations saved.',
|
||||||
|
'settings.projectLocationsScanError': 'Could not scan project locations.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Scan complete: {imported} imported, {existing} already registered.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'No folder selected.',
|
||||||
|
'settings.projectLocationsDuplicate': 'That work base is already added.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Work base · projects are created as subfolders here',
|
||||||
|
'settings.projectLocationsAddFolder': 'Add folder…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Default location',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Make default',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Default project location updated.',
|
||||||
'settings.librarySkills': 'Skills',
|
'settings.librarySkills': 'Skills',
|
||||||
'settings.libraryDesignSystems': 'Design Systems',
|
'settings.libraryDesignSystems': 'Design Systems',
|
||||||
'settings.librarySearch': 'Search...',
|
'settings.librarySearch': 'Search...',
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,9 @@ export const esES: Dict = {
|
||||||
'newproj.fileSingular': 'archivo',
|
'newproj.fileSingular': 'archivo',
|
||||||
'newproj.filePlural': 'archivos',
|
'newproj.filePlural': 'archivos',
|
||||||
'newproj.create': 'Crear',
|
'newproj.create': 'Crear',
|
||||||
|
'newproj.locationLabel': 'Guardar en',
|
||||||
|
'newproj.locationDefault': 'Proyectos de Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base externa',
|
||||||
'newproj.createFromTemplate': 'Crear desde plantilla',
|
'newproj.createFromTemplate': 'Crear desde plantilla',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Guarda primero un proyecto como plantilla (menú Compartir dentro de cualquier proyecto).',
|
'Guarda primero un proyecto como plantilla (menú Compartir dentro de cualquier proyecto).',
|
||||||
|
|
@ -1441,6 +1444,20 @@ export const esES: Dict = {
|
||||||
'settings.designSystemsCategory': 'Categoría',
|
'settings.designSystemsCategory': 'Categoría',
|
||||||
'settings.designSystemsAllCategories': 'Todas las categorías',
|
'settings.designSystemsAllCategories': 'Todas las categorías',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Mostrar en la galería de inicio',
|
'settings.designSystemsShowInHomeGallery': 'Mostrar en la galería de inicio',
|
||||||
|
'settings.projectLocations': 'Ubicaciones de proyectos',
|
||||||
|
'settings.projectLocationsHint': 'Raíces de almacenamiento del espacio de trabajo',
|
||||||
|
'settings.projectLocationsDescription': 'Añade bases de trabajo que pueden contener varias carpetas de proyectos de Open Design. Los proyectos nuevos se guardan como una carpeta dentro de la base seleccionada.',
|
||||||
|
'settings.projectLocationsSaveError': 'No se pudieron guardar las ubicaciones de proyectos. Comprueba que cada ruta sea una carpeta accesible.',
|
||||||
|
'settings.projectLocationsSaved': 'Ubicaciones de proyectos guardadas.',
|
||||||
|
'settings.projectLocationsScanError': 'No se pudieron escanear las ubicaciones de proyectos.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Escaneo completado: {imported} importados, {existing} ya registrados.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'No se seleccionó ninguna carpeta.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Esa base de trabajo ya está añadida.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base de trabajo · los proyectos se crean aquí como subcarpetas',
|
||||||
|
'settings.projectLocationsAddFolder': 'Añadir carpeta…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Ubicación predeterminada',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Hacer predeterminada',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Ubicación de proyecto predeterminada actualizada.',
|
||||||
'settings.librarySkills': 'Habilidades',
|
'settings.librarySkills': 'Habilidades',
|
||||||
'settings.libraryDesignSystems': 'Sistemas de diseño',
|
'settings.libraryDesignSystems': 'Sistemas de diseño',
|
||||||
'settings.librarySearch': 'Buscar...',
|
'settings.librarySearch': 'Buscar...',
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,9 @@ export const fa: Dict = {
|
||||||
'newproj.fileSingular': 'فایل',
|
'newproj.fileSingular': 'فایل',
|
||||||
'newproj.filePlural': 'فایل',
|
'newproj.filePlural': 'فایل',
|
||||||
'newproj.create': 'ایجاد',
|
'newproj.create': 'ایجاد',
|
||||||
|
'newproj.locationLabel': 'ذخیره در',
|
||||||
|
'newproj.locationDefault': 'پروژههای Open Design',
|
||||||
|
'newproj.locationExternalBase': 'پایهٔ خارجی',
|
||||||
'newproj.createLiveArtifact': 'ایجاد مصنوع زنده',
|
'newproj.createLiveArtifact': 'ایجاد مصنوع زنده',
|
||||||
'newproj.createFromTemplate': 'ایجاد از قالب',
|
'newproj.createFromTemplate': 'ایجاد از قالب',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1595,6 +1598,20 @@ export const fa: Dict = {
|
||||||
'settings.designSystemsCategory': 'دستهبندی',
|
'settings.designSystemsCategory': 'دستهبندی',
|
||||||
'settings.designSystemsAllCategories': 'همه دستهبندیها',
|
'settings.designSystemsAllCategories': 'همه دستهبندیها',
|
||||||
'settings.designSystemsShowInHomeGallery': 'نمایش در گالری خانه',
|
'settings.designSystemsShowInHomeGallery': 'نمایش در گالری خانه',
|
||||||
|
'settings.projectLocations': 'مکانهای پروژه',
|
||||||
|
'settings.projectLocationsHint': 'ریشههای ذخیرهسازی فضای کاری',
|
||||||
|
'settings.projectLocationsDescription': 'پایههای کاری اضافه کنید که میتوانند چند پوشهٔ پروژهٔ Open Design را در خود داشته باشند. پروژههای جدید بهصورت یک پوشه داخل پایهٔ انتخابشده ذخیره میشوند.',
|
||||||
|
'settings.projectLocationsSaveError': 'ذخیرهٔ مکانهای پروژه ممکن نشد. بررسی کنید هر مسیر یک پوشهٔ قابل دسترسی باشد.',
|
||||||
|
'settings.projectLocationsSaved': 'مکانهای پروژه ذخیره شد.',
|
||||||
|
'settings.projectLocationsScanError': 'اسکن مکانهای پروژه ممکن نشد.',
|
||||||
|
'settings.projectLocationsScanComplete': 'اسکن کامل شد: {imported} وارد شد، {existing} از قبل ثبت شده بود.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'پوشهای انتخاب نشد.',
|
||||||
|
'settings.projectLocationsDuplicate': 'این پایهٔ کاری قبلاً اضافه شده است.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'پایهٔ کاری · پروژهها اینجا بهصورت زیرپوشه ساخته میشوند',
|
||||||
|
'settings.projectLocationsAddFolder': 'افزودن پوشه…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'مکان پیشفرض',
|
||||||
|
'settings.projectLocationsMakeDefault': 'تنظیم بهعنوان پیشفرض',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'مکان پیشفرض پروژه بهروزرسانی شد.',
|
||||||
'settings.librarySkills': 'مهارتها',
|
'settings.librarySkills': 'مهارتها',
|
||||||
'settings.libraryDesignSystems': 'سیستمهای طراحی',
|
'settings.libraryDesignSystems': 'سیستمهای طراحی',
|
||||||
'settings.librarySearch': 'جستجو...',
|
'settings.librarySearch': 'جستجو...',
|
||||||
|
|
|
||||||
|
|
@ -1101,6 +1101,9 @@ export const fr: Dict = {
|
||||||
'newproj.fileSingular': 'fichier',
|
'newproj.fileSingular': 'fichier',
|
||||||
'newproj.filePlural': 'fichiers',
|
'newproj.filePlural': 'fichiers',
|
||||||
'newproj.create': 'Créer',
|
'newproj.create': 'Créer',
|
||||||
|
'newproj.locationLabel': 'Enregistrer dans',
|
||||||
|
'newproj.locationDefault': 'Projets Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base externe',
|
||||||
'newproj.createLiveArtifact': 'Créer un artefact dynamique',
|
'newproj.createLiveArtifact': 'Créer un artefact dynamique',
|
||||||
'newproj.createFromTemplate': 'Créer depuis le modèle',
|
'newproj.createFromTemplate': 'Créer depuis le modèle',
|
||||||
'newproj.createDisabledTitle': 'Enregistrez d\'abord un projet comme modèle (menu Partager dans un projet).',
|
'newproj.createDisabledTitle': 'Enregistrez d\'abord un projet comme modèle (menu Partager dans un projet).',
|
||||||
|
|
@ -2214,6 +2217,20 @@ export const fr: Dict = {
|
||||||
'settings.designSystemsCategory': 'Catégorie',
|
'settings.designSystemsCategory': 'Catégorie',
|
||||||
'settings.designSystemsAllCategories': 'Toutes les catégories',
|
'settings.designSystemsAllCategories': 'Toutes les catégories',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Afficher dans la galerie d’accueil',
|
'settings.designSystemsShowInHomeGallery': 'Afficher dans la galerie d’accueil',
|
||||||
|
'settings.projectLocations': 'Emplacements de projets',
|
||||||
|
'settings.projectLocationsHint': 'Racines de stockage des espaces de travail',
|
||||||
|
'settings.projectLocationsDescription': 'Ajoutez des bases de travail pouvant contenir plusieurs dossiers de projets Open Design. Les nouveaux projets sont enregistrés comme un dossier dans la base sélectionnée.',
|
||||||
|
'settings.projectLocationsSaveError': 'Impossible d’enregistrer les emplacements de projets. Vérifiez que chaque chemin est un dossier accessible.',
|
||||||
|
'settings.projectLocationsSaved': 'Emplacements de projets enregistrés.',
|
||||||
|
'settings.projectLocationsScanError': 'Impossible d’analyser les emplacements de projets.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Analyse terminée : {imported} importé(s), {existing} déjà enregistré(s).',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Aucun dossier sélectionné.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Cette base de travail est déjà ajoutée.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base de travail · les projets sont créés ici comme sous-dossiers',
|
||||||
|
'settings.projectLocationsAddFolder': 'Ajouter un dossier…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Emplacement par défaut',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Définir par défaut',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Emplacement de projet par défaut mis à jour.',
|
||||||
'settings.librarySkills': 'Compétences',
|
'settings.librarySkills': 'Compétences',
|
||||||
'settings.libraryDesignSystems': 'Systèmes de design',
|
'settings.libraryDesignSystems': 'Systèmes de design',
|
||||||
'settings.librarySearch': 'Rechercher...',
|
'settings.librarySearch': 'Rechercher...',
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const hu: Dict = {
|
||||||
'newproj.fileSingular': 'fájl',
|
'newproj.fileSingular': 'fájl',
|
||||||
'newproj.filePlural': 'fájl',
|
'newproj.filePlural': 'fájl',
|
||||||
'newproj.create': 'Létrehozás',
|
'newproj.create': 'Létrehozás',
|
||||||
|
'newproj.locationLabel': 'Mentés ide',
|
||||||
|
'newproj.locationDefault': 'Open Design projektek',
|
||||||
|
'newproj.locationExternalBase': 'Külső bázis',
|
||||||
'newproj.createFromTemplate': 'Létrehozás sablonból',
|
'newproj.createFromTemplate': 'Létrehozás sablonból',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Először ments el egy projektet sablonként (bármely projekt Megosztás menüjéből).',
|
'Először ments el egy projektet sablonként (bármely projekt Megosztás menüjéből).',
|
||||||
|
|
@ -1562,6 +1565,20 @@ export const hu: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategória',
|
'settings.designSystemsCategory': 'Kategória',
|
||||||
'settings.designSystemsAllCategories': 'Minden kategória',
|
'settings.designSystemsAllCategories': 'Minden kategória',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Megjelenítés a kezdő galériában',
|
'settings.designSystemsShowInHomeGallery': 'Megjelenítés a kezdő galériában',
|
||||||
|
'settings.projectLocations': 'Projekt helyek',
|
||||||
|
'settings.projectLocationsHint': 'Munkaterület tárolási gyökerek',
|
||||||
|
'settings.projectLocationsDescription': 'Adj hozzá munkabázisokat, amelyek több Open Design projektmappát is tartalmazhatnak. Az új projektek mappaként jönnek létre a kiválasztott bázison belül.',
|
||||||
|
'settings.projectLocationsSaveError': 'Nem sikerült menteni a projekt helyeket. Ellenőrizd, hogy minden útvonal elérhető mappa-e.',
|
||||||
|
'settings.projectLocationsSaved': 'Projekt helyek mentve.',
|
||||||
|
'settings.projectLocationsScanError': 'Nem sikerült beolvasni a projekt helyeket.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Beolvasás kész: {imported} importálva, {existing} már regisztrálva.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nincs kiválasztott mappa.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Ez a munkabázis már hozzá van adva.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Munkabázis · a projektek itt almappaként jönnek létre',
|
||||||
|
'settings.projectLocationsAddFolder': 'Mappa hozzáadása…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Alapértelmezett hely',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Legyen alapértelmezett',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Az alapértelmezett projekt hely frissítve.',
|
||||||
'settings.librarySkills': 'Készségek',
|
'settings.librarySkills': 'Készségek',
|
||||||
'settings.libraryDesignSystems': 'Tervezőrendszerek',
|
'settings.libraryDesignSystems': 'Tervezőrendszerek',
|
||||||
'settings.librarySearch': 'Keresés...',
|
'settings.librarySearch': 'Keresés...',
|
||||||
|
|
|
||||||
|
|
@ -672,6 +672,9 @@ export const id: Dict = {
|
||||||
'newproj.fileSingular': 'berkas',
|
'newproj.fileSingular': 'berkas',
|
||||||
'newproj.filePlural': 'berkas',
|
'newproj.filePlural': 'berkas',
|
||||||
'newproj.create': 'Buat',
|
'newproj.create': 'Buat',
|
||||||
|
'newproj.locationLabel': 'Simpan ke',
|
||||||
|
'newproj.locationDefault': 'Proyek Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Basis eksternal',
|
||||||
'newproj.createLiveArtifact': 'Buat live artifact',
|
'newproj.createLiveArtifact': 'Buat live artifact',
|
||||||
'newproj.createFromTemplate': 'Buat dari templat',
|
'newproj.createFromTemplate': 'Buat dari templat',
|
||||||
'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.',
|
'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.',
|
||||||
|
|
@ -1700,6 +1703,20 @@ export const id: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategori',
|
'settings.designSystemsCategory': 'Kategori',
|
||||||
'settings.designSystemsAllCategories': 'Semua kategori',
|
'settings.designSystemsAllCategories': 'Semua kategori',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Tampilkan di galeri beranda',
|
'settings.designSystemsShowInHomeGallery': 'Tampilkan di galeri beranda',
|
||||||
|
'settings.projectLocations': 'Lokasi proyek',
|
||||||
|
'settings.projectLocationsHint': 'Root penyimpanan workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Tambahkan basis kerja yang dapat berisi beberapa folder proyek Open Design. Proyek baru disimpan sebagai folder di dalam basis yang dipilih.',
|
||||||
|
'settings.projectLocationsSaveError': 'Tidak dapat menyimpan lokasi proyek. Pastikan setiap path adalah folder yang dapat diakses.',
|
||||||
|
'settings.projectLocationsSaved': 'Lokasi proyek disimpan.',
|
||||||
|
'settings.projectLocationsScanError': 'Tidak dapat memindai lokasi proyek.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Pemindaian selesai: {imported} diimpor, {existing} sudah terdaftar.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Tidak ada folder yang dipilih.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Basis kerja itu sudah ditambahkan.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Basis kerja · proyek dibuat di sini sebagai subfolder',
|
||||||
|
'settings.projectLocationsAddFolder': 'Tambah folder…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Lokasi default',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Jadikan default',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Lokasi proyek default diperbarui.',
|
||||||
'settings.librarySkills': 'Skill',
|
'settings.librarySkills': 'Skill',
|
||||||
'settings.libraryDesignSystems': 'Sistem desain',
|
'settings.libraryDesignSystems': 'Sistem desain',
|
||||||
'settings.librarySearch': 'Cari...',
|
'settings.librarySearch': 'Cari...',
|
||||||
|
|
|
||||||
|
|
@ -539,6 +539,9 @@ export const it: Dict = {
|
||||||
'newproj.fileSingular': 'file',
|
'newproj.fileSingular': 'file',
|
||||||
'newproj.filePlural': 'file',
|
'newproj.filePlural': 'file',
|
||||||
'newproj.create': 'Crea',
|
'newproj.create': 'Crea',
|
||||||
|
'newproj.locationLabel': 'Salva in',
|
||||||
|
'newproj.locationDefault': 'Progetti Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base esterna',
|
||||||
'newproj.createFromTemplate': 'Crea dal modello',
|
'newproj.createFromTemplate': 'Crea dal modello',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Salva prima un progetto come modello (menu Condividi in un progetto).',
|
'Salva prima un progetto come modello (menu Condividi in un progetto).',
|
||||||
|
|
@ -1432,6 +1435,20 @@ export const it: Dict = {
|
||||||
'settings.designSystemsCategory': 'Categoria',
|
'settings.designSystemsCategory': 'Categoria',
|
||||||
'settings.designSystemsAllCategories': 'Tutte le categorie',
|
'settings.designSystemsAllCategories': 'Tutte le categorie',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Mostra nella galleria iniziale',
|
'settings.designSystemsShowInHomeGallery': 'Mostra nella galleria iniziale',
|
||||||
|
'settings.projectLocations': 'Posizioni dei progetti',
|
||||||
|
'settings.projectLocationsHint': 'Radici di archiviazione workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Aggiungi basi di lavoro che possono contenere più cartelle di progetti Open Design. I nuovi progetti vengono salvati come una cartella nella base selezionata.',
|
||||||
|
'settings.projectLocationsSaveError': 'Impossibile salvare le posizioni dei progetti. Verifica che ogni percorso sia una cartella accessibile.',
|
||||||
|
'settings.projectLocationsSaved': 'Posizioni dei progetti salvate.',
|
||||||
|
'settings.projectLocationsScanError': 'Impossibile scansionare le posizioni dei progetti.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Scansione completata: {imported} importati, {existing} già registrati.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nessuna cartella selezionata.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Questa base di lavoro è già stata aggiunta.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base di lavoro · i progetti vengono creati qui come sottocartelle',
|
||||||
|
'settings.projectLocationsAddFolder': 'Aggiungi cartella…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Posizione predefinita',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Imposta come predefinita',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Posizione progetto predefinita aggiornata.',
|
||||||
'settings.librarySkills': 'Competenze',
|
'settings.librarySkills': 'Competenze',
|
||||||
'settings.libraryDesignSystems': 'Sistemi di design',
|
'settings.libraryDesignSystems': 'Sistemi di design',
|
||||||
'settings.librarySearch': 'Cerca...',
|
'settings.librarySearch': 'Cerca...',
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,9 @@ export const ja: Dict = {
|
||||||
'newproj.fileSingular': 'ファイル',
|
'newproj.fileSingular': 'ファイル',
|
||||||
'newproj.filePlural': 'ファイル',
|
'newproj.filePlural': 'ファイル',
|
||||||
'newproj.create': '作成',
|
'newproj.create': '作成',
|
||||||
|
'newproj.locationLabel': '保存先',
|
||||||
|
'newproj.locationDefault': 'Open Design プロジェクト',
|
||||||
|
'newproj.locationExternalBase': '外部ベース',
|
||||||
'newproj.createFromTemplate': 'テンプレートから作成',
|
'newproj.createFromTemplate': 'テンプレートから作成',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。',
|
'最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。',
|
||||||
|
|
@ -1489,6 +1492,20 @@ export const ja: Dict = {
|
||||||
'settings.designSystemsCategory': 'カテゴリー',
|
'settings.designSystemsCategory': 'カテゴリー',
|
||||||
'settings.designSystemsAllCategories': 'すべてのカテゴリー',
|
'settings.designSystemsAllCategories': 'すべてのカテゴリー',
|
||||||
'settings.designSystemsShowInHomeGallery': 'ホームギャラリーに表示',
|
'settings.designSystemsShowInHomeGallery': 'ホームギャラリーに表示',
|
||||||
|
'settings.projectLocations': 'プロジェクトの場所',
|
||||||
|
'settings.projectLocationsHint': 'ワークスペース保存ルート',
|
||||||
|
'settings.projectLocationsDescription': '複数の Open Design プロジェクトフォルダを含められる作業ベースを追加します。新しいプロジェクトは選択したベース内の 1 つのフォルダとして保存されます。',
|
||||||
|
'settings.projectLocationsSaveError': 'プロジェクトの場所を保存できませんでした。各パスがアクセス可能なフォルダであることを確認してください。',
|
||||||
|
'settings.projectLocationsSaved': 'プロジェクトの場所を保存しました。',
|
||||||
|
'settings.projectLocationsScanError': 'プロジェクトの場所をスキャンできませんでした。',
|
||||||
|
'settings.projectLocationsScanComplete': 'スキャン完了: {imported} 件をインポート、{existing} 件は登録済みです。',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'フォルダが選択されていません。',
|
||||||
|
'settings.projectLocationsDuplicate': 'その作業ベースはすでに追加されています。',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '作業ベース · プロジェクトはここにサブフォルダとして作成されます',
|
||||||
|
'settings.projectLocationsAddFolder': 'フォルダを追加…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'デフォルトの場所',
|
||||||
|
'settings.projectLocationsMakeDefault': 'デフォルトにする',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'デフォルトのプロジェクト場所を更新しました。',
|
||||||
'settings.librarySkills': 'スキル',
|
'settings.librarySkills': 'スキル',
|
||||||
'settings.libraryDesignSystems': 'デザインシステム',
|
'settings.libraryDesignSystems': 'デザインシステム',
|
||||||
'settings.librarySearch': '検索...',
|
'settings.librarySearch': '検索...',
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const ko: Dict = {
|
||||||
'newproj.fileSingular': '파일',
|
'newproj.fileSingular': '파일',
|
||||||
'newproj.filePlural': '파일들',
|
'newproj.filePlural': '파일들',
|
||||||
'newproj.create': '생성',
|
'newproj.create': '생성',
|
||||||
|
'newproj.locationLabel': '저장 위치',
|
||||||
|
'newproj.locationDefault': 'Open Design 프로젝트',
|
||||||
|
'newproj.locationExternalBase': '외부 베이스',
|
||||||
'newproj.createFromTemplate': '템플릿으로 생성',
|
'newproj.createFromTemplate': '템플릿으로 생성',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).',
|
'먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).',
|
||||||
|
|
@ -1602,6 +1605,20 @@ export const ko: Dict = {
|
||||||
'settings.designSystemsCategory': '카테고리',
|
'settings.designSystemsCategory': '카테고리',
|
||||||
'settings.designSystemsAllCategories': '모든 카테고리',
|
'settings.designSystemsAllCategories': '모든 카테고리',
|
||||||
'settings.designSystemsShowInHomeGallery': '홈 갤러리에 표시',
|
'settings.designSystemsShowInHomeGallery': '홈 갤러리에 표시',
|
||||||
|
'settings.projectLocations': '프로젝트 위치',
|
||||||
|
'settings.projectLocationsHint': '워크스페이스 저장 루트',
|
||||||
|
'settings.projectLocationsDescription': '여러 Open Design 프로젝트 폴더를 포함할 수 있는 작업 베이스를 추가합니다. 새 프로젝트는 선택한 베이스 안의 폴더로 저장됩니다.',
|
||||||
|
'settings.projectLocationsSaveError': '프로젝트 위치를 저장할 수 없습니다. 각 경로가 접근 가능한 폴더인지 확인하세요.',
|
||||||
|
'settings.projectLocationsSaved': '프로젝트 위치가 저장되었습니다.',
|
||||||
|
'settings.projectLocationsScanError': '프로젝트 위치를 스캔할 수 없습니다.',
|
||||||
|
'settings.projectLocationsScanComplete': '스캔 완료: {imported}개 가져옴, {existing}개는 이미 등록됨.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': '선택한 폴더가 없습니다.',
|
||||||
|
'settings.projectLocationsDuplicate': '해당 작업 베이스는 이미 추가되었습니다.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '작업 베이스 · 프로젝트는 여기에 하위 폴더로 생성됩니다',
|
||||||
|
'settings.projectLocationsAddFolder': '폴더 추가…',
|
||||||
|
'settings.projectLocationsDefaultBadge': '기본 위치',
|
||||||
|
'settings.projectLocationsMakeDefault': '기본값으로 설정',
|
||||||
|
'settings.projectLocationsDefaultSaved': '기본 프로젝트 위치가 업데이트되었습니다.',
|
||||||
'settings.librarySkills': '스킬',
|
'settings.librarySkills': '스킬',
|
||||||
'settings.libraryDesignSystems': '디자인 시스템',
|
'settings.libraryDesignSystems': '디자인 시스템',
|
||||||
'settings.librarySearch': '검색...',
|
'settings.librarySearch': '검색...',
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const pl: Dict = {
|
||||||
'newproj.fileSingular': 'plik',
|
'newproj.fileSingular': 'plik',
|
||||||
'newproj.filePlural': 'pliki',
|
'newproj.filePlural': 'pliki',
|
||||||
'newproj.create': 'Utwórz',
|
'newproj.create': 'Utwórz',
|
||||||
|
'newproj.locationLabel': 'Zapisz w',
|
||||||
|
'newproj.locationDefault': 'Projekty Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Zewnętrzna baza',
|
||||||
'newproj.createFromTemplate': 'Utwórz z szablonu',
|
'newproj.createFromTemplate': 'Utwórz z szablonu',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).',
|
'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).',
|
||||||
|
|
@ -1552,6 +1555,20 @@ export const pl: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategoria',
|
'settings.designSystemsCategory': 'Kategoria',
|
||||||
'settings.designSystemsAllCategories': 'Wszystkie kategorie',
|
'settings.designSystemsAllCategories': 'Wszystkie kategorie',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Pokaż w galerii głównej',
|
'settings.designSystemsShowInHomeGallery': 'Pokaż w galerii głównej',
|
||||||
|
'settings.projectLocations': 'Lokalizacje projektów',
|
||||||
|
'settings.projectLocationsHint': 'Katalogi główne workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Dodaj bazy robocze, które mogą zawierać wiele folderów projektów Open Design. Nowe projekty są zapisywane jako folder w wybranej bazie.',
|
||||||
|
'settings.projectLocationsSaveError': 'Nie udało się zapisać lokalizacji projektów. Sprawdź, czy każda ścieżka jest dostępnym folderem.',
|
||||||
|
'settings.projectLocationsSaved': 'Lokalizacje projektów zapisane.',
|
||||||
|
'settings.projectLocationsScanError': 'Nie udało się przeskanować lokalizacji projektów.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Skanowanie zakończone: zaimportowano {imported}, już zarejestrowano {existing}.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nie wybrano folderu.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Ta baza robocza jest już dodana.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Baza robocza · projekty są tworzone tutaj jako podfoldery',
|
||||||
|
'settings.projectLocationsAddFolder': 'Dodaj folder…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Lokalizacja domyślna',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Ustaw jako domyślną',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Domyślna lokalizacja projektu zaktualizowana.',
|
||||||
'settings.librarySkills': 'Umiejętności',
|
'settings.librarySkills': 'Umiejętności',
|
||||||
'settings.libraryDesignSystems': 'Systemy projektowe',
|
'settings.libraryDesignSystems': 'Systemy projektowe',
|
||||||
'settings.librarySearch': 'Szukaj...',
|
'settings.librarySearch': 'Szukaj...',
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,9 @@ export const ptBR: Dict = {
|
||||||
'newproj.fileSingular': 'arquivo',
|
'newproj.fileSingular': 'arquivo',
|
||||||
'newproj.filePlural': 'arquivos',
|
'newproj.filePlural': 'arquivos',
|
||||||
'newproj.create': 'Criar',
|
'newproj.create': 'Criar',
|
||||||
|
'newproj.locationLabel': 'Salvar em',
|
||||||
|
'newproj.locationDefault': 'Projetos Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base externa',
|
||||||
'newproj.createLiveArtifact': 'Criar artefato live',
|
'newproj.createLiveArtifact': 'Criar artefato live',
|
||||||
'newproj.createFromTemplate': 'Criar a partir do template',
|
'newproj.createFromTemplate': 'Criar a partir do template',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1593,6 +1596,20 @@ export const ptBR: Dict = {
|
||||||
'settings.designSystemsCategory': 'Categoria',
|
'settings.designSystemsCategory': 'Categoria',
|
||||||
'settings.designSystemsAllCategories': 'Todas as categorias',
|
'settings.designSystemsAllCategories': 'Todas as categorias',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Mostrar na galeria inicial',
|
'settings.designSystemsShowInHomeGallery': 'Mostrar na galeria inicial',
|
||||||
|
'settings.projectLocations': 'Locais de projetos',
|
||||||
|
'settings.projectLocationsHint': 'Raízes de armazenamento do workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Adicione bases de trabalho que podem conter várias pastas de projetos do Open Design. Novos projetos são salvos como uma pasta dentro da base selecionada.',
|
||||||
|
'settings.projectLocationsSaveError': 'Não foi possível salvar os locais de projetos. Verifique se cada caminho é uma pasta acessível.',
|
||||||
|
'settings.projectLocationsSaved': 'Locais de projetos salvos.',
|
||||||
|
'settings.projectLocationsScanError': 'Não foi possível escanear os locais de projetos.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Escaneamento concluído: {imported} importados, {existing} já registrados.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nenhuma pasta selecionada.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Essa base de trabalho já foi adicionada.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base de trabalho · projetos são criados aqui como subpastas',
|
||||||
|
'settings.projectLocationsAddFolder': 'Adicionar pasta…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Local padrão',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Tornar padrão',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Local padrão do projeto atualizado.',
|
||||||
'settings.librarySkills': 'Habilidades',
|
'settings.librarySkills': 'Habilidades',
|
||||||
'settings.libraryDesignSystems': 'Sistemas de design',
|
'settings.libraryDesignSystems': 'Sistemas de design',
|
||||||
'settings.librarySearch': 'Pesquisar...',
|
'settings.librarySearch': 'Pesquisar...',
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,9 @@ export const ru: Dict = {
|
||||||
'newproj.fileSingular': 'файл',
|
'newproj.fileSingular': 'файл',
|
||||||
'newproj.filePlural': 'файлов',
|
'newproj.filePlural': 'файлов',
|
||||||
'newproj.create': 'Создать',
|
'newproj.create': 'Создать',
|
||||||
|
'newproj.locationLabel': 'Сохранить в',
|
||||||
|
'newproj.locationDefault': 'Проекты Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Внешняя база',
|
||||||
'newproj.createLiveArtifact': 'Создать live-артефакт',
|
'newproj.createLiveArtifact': 'Создать live-артефакт',
|
||||||
'newproj.createFromTemplate': 'Создать из шаблона',
|
'newproj.createFromTemplate': 'Создать из шаблона',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1593,6 +1596,20 @@ export const ru: Dict = {
|
||||||
'settings.designSystemsCategory': 'Категория',
|
'settings.designSystemsCategory': 'Категория',
|
||||||
'settings.designSystemsAllCategories': 'Все категории',
|
'settings.designSystemsAllCategories': 'Все категории',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Показывать в домашней галерее',
|
'settings.designSystemsShowInHomeGallery': 'Показывать в домашней галерее',
|
||||||
|
'settings.projectLocations': 'Расположения проектов',
|
||||||
|
'settings.projectLocationsHint': 'Корни хранения рабочих пространств',
|
||||||
|
'settings.projectLocationsDescription': 'Добавьте рабочие базы, которые могут содержать несколько папок проектов Open Design. Новые проекты сохраняются как папка внутри выбранной базы.',
|
||||||
|
'settings.projectLocationsSaveError': 'Не удалось сохранить расположения проектов. Проверьте, что каждый путь является доступной папкой.',
|
||||||
|
'settings.projectLocationsSaved': 'Расположения проектов сохранены.',
|
||||||
|
'settings.projectLocationsScanError': 'Не удалось просканировать расположения проектов.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Сканирование завершено: импортировано {imported}, уже зарегистрировано {existing}.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Папка не выбрана.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Эта рабочая база уже добавлена.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Рабочая база · проекты создаются здесь как подпапки',
|
||||||
|
'settings.projectLocationsAddFolder': 'Добавить папку…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Расположение по умолчанию',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Сделать по умолчанию',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Расположение проекта по умолчанию обновлено.',
|
||||||
'settings.librarySkills': 'Навыки',
|
'settings.librarySkills': 'Навыки',
|
||||||
'settings.libraryDesignSystems': 'Системы дизайна',
|
'settings.libraryDesignSystems': 'Системы дизайна',
|
||||||
'settings.librarySearch': 'Поиск...',
|
'settings.librarySearch': 'Поиск...',
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,9 @@ export const th: Dict = {
|
||||||
'newproj.fileSingular': 'ไฟล์',
|
'newproj.fileSingular': 'ไฟล์',
|
||||||
'newproj.filePlural': 'ไฟล์',
|
'newproj.filePlural': 'ไฟล์',
|
||||||
'newproj.create': 'สร้าง',
|
'newproj.create': 'สร้าง',
|
||||||
|
'newproj.locationLabel': 'บันทึกไปยัง',
|
||||||
|
'newproj.locationDefault': 'โปรเจกต์ Open Design',
|
||||||
|
'newproj.locationExternalBase': 'ฐานภายนอก',
|
||||||
'newproj.createLiveArtifact': 'สร้าง live artifact',
|
'newproj.createLiveArtifact': 'สร้าง live artifact',
|
||||||
'newproj.createFromTemplate': 'สร้างจากเทมเพลต',
|
'newproj.createFromTemplate': 'สร้างจากเทมเพลต',
|
||||||
'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน',
|
'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน',
|
||||||
|
|
@ -1469,6 +1472,20 @@ export const th: Dict = {
|
||||||
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
|
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
|
||||||
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
|
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
|
||||||
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง',
|
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง',
|
||||||
|
'settings.projectLocations': 'ตำแหน่งโปรเจกต์',
|
||||||
|
'settings.projectLocationsHint': 'รากที่เก็บเวิร์กสเปซ',
|
||||||
|
'settings.projectLocationsDescription': 'เพิ่มฐานงานที่สามารถเก็บโฟลเดอร์โปรเจกต์ Open Design ได้หลายรายการ โปรเจกต์ใหม่จะถูกบันทึกเป็นหนึ่งโฟลเดอร์ภายในฐานที่เลือก',
|
||||||
|
'settings.projectLocationsSaveError': 'ไม่สามารถบันทึกตำแหน่งโปรเจกต์ได้ ตรวจสอบว่าแต่ละพาธเป็นโฟลเดอร์ที่เข้าถึงได้',
|
||||||
|
'settings.projectLocationsSaved': 'บันทึกตำแหน่งโปรเจกต์แล้ว',
|
||||||
|
'settings.projectLocationsScanError': 'ไม่สามารถสแกนตำแหน่งโปรเจกต์ได้',
|
||||||
|
'settings.projectLocationsScanComplete': 'สแกนเสร็จแล้ว: นำเข้า {imported} รายการ, ลงทะเบียนไว้แล้ว {existing} รายการ',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'ไม่ได้เลือกโฟลเดอร์',
|
||||||
|
'settings.projectLocationsDuplicate': 'เพิ่มฐานงานนี้ไว้แล้ว',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'ฐานงาน · โปรเจกต์จะถูกสร้างเป็นโฟลเดอร์ย่อยที่นี่',
|
||||||
|
'settings.projectLocationsAddFolder': 'เพิ่มโฟลเดอร์…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'ตำแหน่งเริ่มต้น',
|
||||||
|
'settings.projectLocationsMakeDefault': 'ตั้งเป็นค่าเริ่มต้น',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'อัปเดตตำแหน่งโปรเจกต์เริ่มต้นแล้ว',
|
||||||
'settings.librarySkills': 'พวก Skills',
|
'settings.librarySkills': 'พวก Skills',
|
||||||
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
|
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
|
||||||
'settings.librarySearch': 'ต้องการหาสิ่งใด…',
|
'settings.librarySearch': 'ต้องการหาสิ่งใด…',
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,9 @@ export const tr: Dict = {
|
||||||
'newproj.fileSingular': 'dosya',
|
'newproj.fileSingular': 'dosya',
|
||||||
'newproj.filePlural': 'dosyalar',
|
'newproj.filePlural': 'dosyalar',
|
||||||
'newproj.create': 'Oluştur',
|
'newproj.create': 'Oluştur',
|
||||||
|
'newproj.locationLabel': 'Şuraya kaydet',
|
||||||
|
'newproj.locationDefault': 'Open Design projeleri',
|
||||||
|
'newproj.locationExternalBase': 'Harici taban',
|
||||||
'newproj.createFromTemplate': 'Şablondan oluştur',
|
'newproj.createFromTemplate': 'Şablondan oluştur',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Önce bir projeyi şablon olarak kaydedin (herhangi bir projenin içinde Paylaş menüsünden).',
|
'Önce bir projeyi şablon olarak kaydedin (herhangi bir projenin içinde Paylaş menüsünden).',
|
||||||
|
|
@ -1539,6 +1542,20 @@ export const tr: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategori',
|
'settings.designSystemsCategory': 'Kategori',
|
||||||
'settings.designSystemsAllCategories': 'Tüm kategoriler',
|
'settings.designSystemsAllCategories': 'Tüm kategoriler',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Ana galeride göster',
|
'settings.designSystemsShowInHomeGallery': 'Ana galeride göster',
|
||||||
|
'settings.projectLocations': 'Proje konumları',
|
||||||
|
'settings.projectLocationsHint': 'Çalışma alanı depolama kökleri',
|
||||||
|
'settings.projectLocationsDescription': 'Birden fazla Open Design proje klasörü içerebilen çalışma tabanları ekleyin. Yeni projeler seçilen tabanın içinde bir klasör olarak kaydedilir.',
|
||||||
|
'settings.projectLocationsSaveError': 'Proje konumları kaydedilemedi. Her yolun erişilebilir bir klasör olduğunu kontrol edin.',
|
||||||
|
'settings.projectLocationsSaved': 'Proje konumları kaydedildi.',
|
||||||
|
'settings.projectLocationsScanError': 'Proje konumları taranamadı.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Tarama tamamlandı: {imported} içe aktarıldı, {existing} zaten kayıtlı.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Klasör seçilmedi.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Bu çalışma tabanı zaten eklendi.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Çalışma tabanı · projeler burada alt klasörler olarak oluşturulur',
|
||||||
|
'settings.projectLocationsAddFolder': 'Klasör ekle…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Varsayılan konum',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Varsayılan yap',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Varsayılan proje konumu güncellendi.',
|
||||||
'settings.librarySkills': 'Beceriler',
|
'settings.librarySkills': 'Beceriler',
|
||||||
'settings.libraryDesignSystems': 'Tasarım sistemleri',
|
'settings.libraryDesignSystems': 'Tasarım sistemleri',
|
||||||
'settings.librarySearch': 'Ara...',
|
'settings.librarySearch': 'Ara...',
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,9 @@ export const uk: Dict = {
|
||||||
'newproj.fileSingular': 'файл',
|
'newproj.fileSingular': 'файл',
|
||||||
'newproj.filePlural': 'файли',
|
'newproj.filePlural': 'файли',
|
||||||
'newproj.create': 'Створити',
|
'newproj.create': 'Створити',
|
||||||
|
'newproj.locationLabel': 'Зберегти в',
|
||||||
|
'newproj.locationDefault': 'Проєкти Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Зовнішня база',
|
||||||
'newproj.createLiveArtifact': 'Створити live-артефакт',
|
'newproj.createLiveArtifact': 'Створити live-артефакт',
|
||||||
'newproj.createFromTemplate': 'Створити з шаблону',
|
'newproj.createFromTemplate': 'Створити з шаблону',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1594,6 +1597,20 @@ export const uk: Dict = {
|
||||||
'settings.designSystemsCategory': 'Категорія',
|
'settings.designSystemsCategory': 'Категорія',
|
||||||
'settings.designSystemsAllCategories': 'Усі категорії',
|
'settings.designSystemsAllCategories': 'Усі категорії',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Показувати в домашній галереї',
|
'settings.designSystemsShowInHomeGallery': 'Показувати в домашній галереї',
|
||||||
|
'settings.projectLocations': 'Розташування проєктів',
|
||||||
|
'settings.projectLocationsHint': 'Корені зберігання робочих просторів',
|
||||||
|
'settings.projectLocationsDescription': 'Додайте робочі бази, які можуть містити кілька тек проєктів Open Design. Нові проєкти зберігаються як тека всередині вибраної бази.',
|
||||||
|
'settings.projectLocationsSaveError': 'Не вдалося зберегти розташування проєктів. Перевірте, що кожен шлях є доступною текою.',
|
||||||
|
'settings.projectLocationsSaved': 'Розташування проєктів збережено.',
|
||||||
|
'settings.projectLocationsScanError': 'Не вдалося просканувати розташування проєктів.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Сканування завершено: імпортовано {imported}, уже зареєстровано {existing}.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Теку не вибрано.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Цю робочу базу вже додано.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Робоча база · проєкти створюються тут як підтеки',
|
||||||
|
'settings.projectLocationsAddFolder': 'Додати теку…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Типове розташування',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Зробити типовим',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Типове розташування проєкту оновлено.',
|
||||||
'settings.librarySkills': 'Навички',
|
'settings.librarySkills': 'Навички',
|
||||||
'settings.libraryDesignSystems': 'Системи дизайну',
|
'settings.libraryDesignSystems': 'Системи дизайну',
|
||||||
'settings.librarySearch': 'Пошук...',
|
'settings.librarySearch': 'Пошук...',
|
||||||
|
|
|
||||||
|
|
@ -1152,6 +1152,9 @@ export const zhCN: Dict = {
|
||||||
'newproj.fileSingular': '个文件',
|
'newproj.fileSingular': '个文件',
|
||||||
'newproj.filePlural': '个文件',
|
'newproj.filePlural': '个文件',
|
||||||
'newproj.create': '创建',
|
'newproj.create': '创建',
|
||||||
|
'newproj.locationLabel': '保存到',
|
||||||
|
'newproj.locationDefault': 'Open Design 项目',
|
||||||
|
'newproj.locationExternalBase': '外部基目录',
|
||||||
'newproj.createLiveArtifact': '创建实时制品',
|
'newproj.createLiveArtifact': '创建实时制品',
|
||||||
'newproj.createFromTemplate': '基于模板创建',
|
'newproj.createFromTemplate': '基于模板创建',
|
||||||
'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。',
|
'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。',
|
||||||
|
|
@ -2299,6 +2302,20 @@ export const zhCN: Dict = {
|
||||||
'settings.designSystemsCategory': '分类',
|
'settings.designSystemsCategory': '分类',
|
||||||
'settings.designSystemsAllCategories': '所有分类',
|
'settings.designSystemsAllCategories': '所有分类',
|
||||||
'settings.designSystemsShowInHomeGallery': '在首页 Gallery 中显示',
|
'settings.designSystemsShowInHomeGallery': '在首页 Gallery 中显示',
|
||||||
|
'settings.projectLocations': '项目位置',
|
||||||
|
'settings.projectLocationsHint': '工作区存储根目录',
|
||||||
|
'settings.projectLocationsDescription': '添加可包含多个 Open Design 项目文件夹的工作基目录。新项目会作为所选基目录内的一个文件夹保存。',
|
||||||
|
'settings.projectLocationsSaveError': '无法保存项目位置。请检查每个路径都是可访问的文件夹。',
|
||||||
|
'settings.projectLocationsSaved': '项目位置已保存。',
|
||||||
|
'settings.projectLocationsScanError': '无法扫描项目位置。',
|
||||||
|
'settings.projectLocationsScanComplete': '扫描完成:已导入 {imported} 个,已有 {existing} 个。',
|
||||||
|
'settings.projectLocationsNoFolderSelected': '未选择文件夹。',
|
||||||
|
'settings.projectLocationsDuplicate': '这个工作基目录已添加。',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '工作基目录 · 项目会作为子文件夹创建在这里',
|
||||||
|
'settings.projectLocationsAddFolder': '添加文件夹…',
|
||||||
|
'settings.projectLocationsDefaultBadge': '默认位置',
|
||||||
|
'settings.projectLocationsMakeDefault': '设为默认',
|
||||||
|
'settings.projectLocationsDefaultSaved': '默认项目位置已更新。',
|
||||||
'settings.librarySkills': '技能',
|
'settings.librarySkills': '技能',
|
||||||
'settings.libraryDesignSystems': '设计系统',
|
'settings.libraryDesignSystems': '设计系统',
|
||||||
'settings.librarySearch': '搜索...',
|
'settings.librarySearch': '搜索...',
|
||||||
|
|
|
||||||
|
|
@ -754,6 +754,9 @@ export const zhTW: Dict = {
|
||||||
'newproj.fileSingular': '個檔案',
|
'newproj.fileSingular': '個檔案',
|
||||||
'newproj.filePlural': '個檔案',
|
'newproj.filePlural': '個檔案',
|
||||||
'newproj.create': '建立',
|
'newproj.create': '建立',
|
||||||
|
'newproj.locationLabel': '儲存到',
|
||||||
|
'newproj.locationDefault': 'Open Design 專案',
|
||||||
|
'newproj.locationExternalBase': '外部基目錄',
|
||||||
'newproj.createLiveArtifact': '建立即時成品',
|
'newproj.createLiveArtifact': '建立即時成品',
|
||||||
'newproj.createFromTemplate': '基於範本建立',
|
'newproj.createFromTemplate': '基於範本建立',
|
||||||
'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。',
|
'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。',
|
||||||
|
|
@ -1851,6 +1854,20 @@ export const zhTW: Dict = {
|
||||||
'settings.designSystemsCategory': '分類',
|
'settings.designSystemsCategory': '分類',
|
||||||
'settings.designSystemsAllCategories': '所有分類',
|
'settings.designSystemsAllCategories': '所有分類',
|
||||||
'settings.designSystemsShowInHomeGallery': '在首頁 Gallery 中顯示',
|
'settings.designSystemsShowInHomeGallery': '在首頁 Gallery 中顯示',
|
||||||
|
'settings.projectLocations': '專案位置',
|
||||||
|
'settings.projectLocationsHint': '工作區儲存根目錄',
|
||||||
|
'settings.projectLocationsDescription': '新增可包含多個 Open Design 專案資料夾的工作基目錄。新專案會儲存為所選基目錄中的一個資料夾。',
|
||||||
|
'settings.projectLocationsSaveError': '無法儲存專案位置。請確認每個路徑都是可存取的資料夾。',
|
||||||
|
'settings.projectLocationsSaved': '專案位置已儲存。',
|
||||||
|
'settings.projectLocationsScanError': '無法掃描專案位置。',
|
||||||
|
'settings.projectLocationsScanComplete': '掃描完成:已匯入 {imported} 個,已有 {existing} 個。',
|
||||||
|
'settings.projectLocationsNoFolderSelected': '未選取資料夾。',
|
||||||
|
'settings.projectLocationsDuplicate': '這個工作基目錄已新增。',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '工作基目錄 · 專案會在這裡建立為子資料夾',
|
||||||
|
'settings.projectLocationsAddFolder': '新增資料夾…',
|
||||||
|
'settings.projectLocationsDefaultBadge': '預設位置',
|
||||||
|
'settings.projectLocationsMakeDefault': '設為預設',
|
||||||
|
'settings.projectLocationsDefaultSaved': '預設專案位置已更新。',
|
||||||
'settings.librarySkills': '技能',
|
'settings.librarySkills': '技能',
|
||||||
'settings.libraryDesignSystems': '設計系統',
|
'settings.libraryDesignSystems': '設計系統',
|
||||||
'settings.librarySearch': '搜尋...',
|
'settings.librarySearch': '搜尋...',
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,20 @@ export interface Dict {
|
||||||
'settings.designSystemsCategory': string;
|
'settings.designSystemsCategory': string;
|
||||||
'settings.designSystemsAllCategories': string;
|
'settings.designSystemsAllCategories': string;
|
||||||
'settings.designSystemsShowInHomeGallery': string;
|
'settings.designSystemsShowInHomeGallery': string;
|
||||||
|
'settings.projectLocations': string;
|
||||||
|
'settings.projectLocationsHint': string;
|
||||||
|
'settings.projectLocationsDescription': string;
|
||||||
|
'settings.projectLocationsSaveError': string;
|
||||||
|
'settings.projectLocationsSaved': string;
|
||||||
|
'settings.projectLocationsScanError': string;
|
||||||
|
'settings.projectLocationsScanComplete': string;
|
||||||
|
'settings.projectLocationsNoFolderSelected': string;
|
||||||
|
'settings.projectLocationsDuplicate': string;
|
||||||
|
'settings.projectLocationsWorkBaseMeta': string;
|
||||||
|
'settings.projectLocationsAddFolder': string;
|
||||||
|
'settings.projectLocationsDefaultBadge': string;
|
||||||
|
'settings.projectLocationsMakeDefault': string;
|
||||||
|
'settings.projectLocationsDefaultSaved': string;
|
||||||
'settings.librarySkills': string;
|
'settings.librarySkills': string;
|
||||||
'settings.libraryDesignSystems': string;
|
'settings.libraryDesignSystems': string;
|
||||||
'settings.librarySearch': string;
|
'settings.librarySearch': string;
|
||||||
|
|
@ -1431,6 +1445,9 @@ export interface Dict {
|
||||||
'newproj.fileSingular': string;
|
'newproj.fileSingular': string;
|
||||||
'newproj.filePlural': string;
|
'newproj.filePlural': string;
|
||||||
'newproj.create': string;
|
'newproj.create': string;
|
||||||
|
'newproj.locationLabel': string;
|
||||||
|
'newproj.locationDefault': string;
|
||||||
|
'newproj.locationExternalBase': string;
|
||||||
'newproj.createLiveArtifact': string;
|
'newproj.createLiveArtifact': string;
|
||||||
'newproj.createFromTemplate': string;
|
'newproj.createFromTemplate': string;
|
||||||
'newproj.createDisabledTitle': string;
|
'newproj.createDisabledTitle': string;
|
||||||
|
|
|
||||||
|
|
@ -161,10 +161,11 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
|
||||||
{
|
{
|
||||||
id: 'fal',
|
id: 'fal',
|
||||||
label: 'Fal.ai',
|
label: 'Fal.ai',
|
||||||
hint: 'Sora / Seedance / Veo / FLUX',
|
hint: 'FLUX / Sora / Veo / Wan / Ideogram / Recraft and any fal-ai/* model',
|
||||||
integrated: false,
|
integrated: true,
|
||||||
defaultBaseUrl: 'https://fal.run',
|
defaultBaseUrl: 'https://fal.run',
|
||||||
docsUrl: 'https://fal.ai/dashboard/keys',
|
docsUrl: 'https://fal.ai/dashboard/keys',
|
||||||
|
supportsCustomModel: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'leonardo',
|
id: 'leonardo',
|
||||||
|
|
@ -438,9 +439,16 @@ export const IMAGE_MODELS: MediaModel[] = [
|
||||||
{ id: 'imagen-3', label: 'imagen-3', hint: 'Google', provider: 'google', caps: ['t2i'] },
|
{ id: 'imagen-3', label: 'imagen-3', hint: 'Google', provider: 'google', caps: ['t2i'] },
|
||||||
{ id: 'gemini-3-pro-image-preview', label: 'gemini-3-pro-image', hint: 'Google · Nano Banana Pro', provider: 'google', caps: ['t2i', 'i2i'] },
|
{ id: 'gemini-3-pro-image-preview', label: 'gemini-3-pro-image', hint: 'Google · Nano Banana Pro', provider: 'google', caps: ['t2i', 'i2i'] },
|
||||||
|
|
||||||
// Replicate / Fal hosted image models.
|
// Replicate hosted image models.
|
||||||
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
|
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
|
||||||
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
|
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
|
||||||
|
|
||||||
|
// Fal.ai image models — pass any fal-ai/* path as model for custom models.
|
||||||
|
{ id: 'flux-pro-ultra', label: 'flux-pro-ultra', hint: 'Fal · FLUX 1.1 Pro Ultra · highest quality', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'flux-dev-fal', label: 'flux-dev (fal)', hint: 'Fal · FLUX Dev · open weights', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'flux-schnell-fal', label: 'flux-schnell (fal)', hint: 'Fal · FLUX Schnell · fastest / cheapest', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'ideogram-v3-fal', label: 'ideogram-v3', hint: 'Fal · Ideogram v3 · typography + design', provider: 'fal', caps: ['t2i'] },
|
||||||
|
{ id: 'recraft-v3-fal', label: 'recraft-v3', hint: 'Fal · Recraft v3 · vector + illustration', provider: 'fal', caps: ['t2i'] },
|
||||||
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
|
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
|
||||||
|
|
||||||
// Leonardo.ai models
|
// Leonardo.ai models
|
||||||
|
|
@ -538,9 +546,15 @@ export const VIDEO_MODELS: MediaModel[] = [
|
||||||
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
|
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
|
||||||
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
|
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
|
||||||
|
|
||||||
// OpenAI Sora (via Fal hosting today).
|
// Fal.ai video models — pass any fal-ai/* path as model for custom models.
|
||||||
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
|
{ id: 'veo-3-fal', label: 'veo-3 (fal)', hint: 'Fal · Google Veo 3 · sound-on', provider: 'fal', caps: ['t2v', 'audio'] },
|
||||||
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
|
{ id: 'veo-2-fal', label: 'veo-2 (fal)', hint: 'Fal · Google Veo 2', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'wan-2.1-t2v', label: 'wan-2.1-t2v', hint: 'Fal · Wan 2.1 text-to-video', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'wan-2.1-i2v', label: 'wan-2.1-i2v', hint: 'Fal · Wan 2.1 image-to-video', provider: 'fal', caps: ['i2v'] },
|
||||||
|
{ id: 'seedance-1-pro-fal', label: 'seedance-1-pro (fal)', hint: 'Fal · Seedance 1 Pro', provider: 'fal', caps: ['t2v', 'i2v'] },
|
||||||
|
{ id: 'kling-2.1-t2v-fal', label: 'kling-2.1 (fal)', hint: 'Fal · Kling 2.1 Pro text-to-video', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'sora-2', label: 'sora-2', hint: 'Fal · OpenAI Sora 2', provider: 'fal', caps: ['t2v'] },
|
||||||
|
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'Fal · OpenAI Sora 2 Pro', provider: 'fal', caps: ['t2v'] },
|
||||||
|
|
||||||
// MiniMax video.
|
// MiniMax video.
|
||||||
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
|
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||||
pet: DEFAULT_PET,
|
pet: DEFAULT_PET,
|
||||||
notifications: DEFAULT_NOTIFICATIONS,
|
notifications: DEFAULT_NOTIFICATIONS,
|
||||||
orbit: DEFAULT_ORBIT,
|
orbit: DEFAULT_ORBIT,
|
||||||
|
projectLocations: [],
|
||||||
|
defaultProjectLocationId: 'default',
|
||||||
// Telemetry defaults to ON so fresh-install users emit onboarding /
|
// Telemetry defaults to ON so fresh-install users emit onboarding /
|
||||||
// ui_click events from the first frame. The disclosure modal still
|
// ui_click events from the first frame. The disclosure modal still
|
||||||
// appears after `onboardingCompleted` flips, and Settings → Privacy
|
// appears after `onboardingCompleted` flips, and Settings → Privacy
|
||||||
|
|
@ -688,6 +690,12 @@ export function mergeDaemonConfig(
|
||||||
if (daemonConfig.customInstructions !== undefined) {
|
if (daemonConfig.customInstructions !== undefined) {
|
||||||
next.customInstructions = daemonConfig.customInstructions ?? undefined;
|
next.customInstructions = daemonConfig.customInstructions ?? undefined;
|
||||||
}
|
}
|
||||||
|
if (daemonConfig.projectLocations !== undefined) {
|
||||||
|
next.projectLocations = daemonConfig.projectLocations;
|
||||||
|
}
|
||||||
|
if (daemonConfig.defaultProjectLocationId !== undefined) {
|
||||||
|
next.defaultProjectLocationId = daemonConfig.defaultProjectLocationId ?? 'default';
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,6 +810,8 @@ export async function syncConfigToDaemon(
|
||||||
telemetry: config.telemetry,
|
telemetry: config.telemetry,
|
||||||
privacyDecisionAt: config.privacyDecisionAt,
|
privacyDecisionAt: config.privacyDecisionAt,
|
||||||
customInstructions: config.customInstructions ?? null,
|
customInstructions: config.customInstructions ?? null,
|
||||||
|
projectLocations: config.projectLocations ?? [],
|
||||||
|
defaultProjectLocationId: config.defaultProjectLocationId ?? 'default',
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/app-config', {
|
const response = await fetch('/api/app-config', {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue