mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon): add link code folder support for agent context (#455)
* feat(daemon): add link code folder support for agent context Users can now link local code directories to a project so the AI agent reads their source code via --add-dir when generating designs. The import menu's "Link code folder" item opens a native OS folder picker, and linked folders appear as removable chips below the chat input. - Add linkedDirs field to ProjectMetadata contract - Add POST /api/dialog/open-folder endpoint (osascript/zenity/PowerShell) - Add validateLinkedDirs with path safety checks (absolute, exists, blocklist) - Append linked dirs to extraAllowedDirs in startChatRun - Add system prompt hint listing linked code folders - Render linked folder chips in ChatComposer with add/remove - Add i18n strings for all 16 locales - Add 8 unit tests for validateLinkedDirs * fix: address PR review feedback - Add JSDoc type annotations to validateLinkedDirs for strict mode - Check path.isAbsolute before resolve to catch relative inputs - Allow linking when projectMetadata is undefined (default to prototype) - Remove redundant PATCH in ProjectView callback * fix: use inline TS annotations instead of JSDoc in linked-dirs.ts * fix(daemon): harden linked-dirs validation against security bypasses - Resolve symlinks with realpathSync.native before checking blocklist - Reject filesystem root (/) and drive roots as linked dirs - Canonicalize blocklist entries to handle macOS /etc -> /private/etc - Validate linkedDirs on project creation, not just PATCH - Re-validate persisted linkedDirs in startChatRun before use - Add tests for root, symlink-to-blocked-dir, and realpath resolution * fix: narrow union type before accessing .error in tests
This commit is contained in:
parent
c219be622d
commit
cc8add4f09
26 changed files with 484 additions and 28 deletions
63
apps/daemon/src/linked-dirs.ts
Normal file
63
apps/daemon/src/linked-dirs.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const BLOCKED_CANONICAL = (() => {
|
||||
const raw =
|
||||
process.platform === 'win32'
|
||||
? ['C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)']
|
||||
: ['/etc', '/proc', '/sys', '/dev', '/boot'];
|
||||
const set = new Set<string>(raw);
|
||||
for (const p of raw) {
|
||||
try { set.add(fs.realpathSync.native(p)); } catch { /* not resolvable, keep as-is */ }
|
||||
}
|
||||
return [...set];
|
||||
})();
|
||||
|
||||
const WIN_ROOT_RE = /^[A-Za-z]:\\?$/;
|
||||
|
||||
function isFilesystemRoot(p: string): boolean {
|
||||
if (process.platform === 'win32') return WIN_ROOT_RE.test(p);
|
||||
return p === '/';
|
||||
}
|
||||
|
||||
function isBlocked(realPath: string): boolean {
|
||||
if (isFilesystemRoot(realPath)) return true;
|
||||
return BLOCKED_CANONICAL.some(
|
||||
(p: string) =>
|
||||
realPath === p ||
|
||||
realPath.startsWith(p + path.sep) ||
|
||||
p.startsWith(realPath + path.sep),
|
||||
);
|
||||
}
|
||||
|
||||
export function validateLinkedDirs(
|
||||
dirs: unknown,
|
||||
): { dirs: string[]; error?: undefined } | { error: string; dirs?: undefined } {
|
||||
if (!Array.isArray(dirs)) return { error: 'linkedDirs must be an array' };
|
||||
const validated: string[] = [];
|
||||
for (const d of dirs) {
|
||||
if (typeof d !== 'string' || !d.trim()) {
|
||||
return { error: 'each linked dir must be a non-empty string' };
|
||||
}
|
||||
if (!path.isAbsolute(d)) {
|
||||
return { error: `linked dir must be an absolute path: ${d}` };
|
||||
}
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = fs.realpathSync.native(path.resolve(d));
|
||||
} catch {
|
||||
return { error: `directory does not exist or is not accessible: ${d}` };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(realPath);
|
||||
if (!stat.isDirectory()) return { error: `not a directory: ${d}` };
|
||||
} catch {
|
||||
return { error: `directory does not exist or is not accessible: ${d}` };
|
||||
}
|
||||
if (isBlocked(realPath)) {
|
||||
return { error: `system directory not allowed: ${d}` };
|
||||
}
|
||||
validated.push(realPath);
|
||||
}
|
||||
return { dirs: [...new Set(validated)] };
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// @ts-nocheck
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
spawnEnvForAgent,
|
||||
} from './agents.js';
|
||||
import { findSkillById, listSkills } from './skills.js';
|
||||
import { validateLinkedDirs } from './linked-dirs.js';
|
||||
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
||||
import { syncCommunityPets } from './community-pets-sync.js';
|
||||
import { listDesignSystems, readDesignSystem } from './design-systems.js';
|
||||
|
|
@ -415,6 +416,44 @@ function sanitizeArchiveFilename(raw) {
|
|||
return cleaned;
|
||||
}
|
||||
|
||||
function openNativeFolderDialog() {
|
||||
return new Promise((resolve) => {
|
||||
const platform = process.platform;
|
||||
if (platform === 'darwin') {
|
||||
execFile(
|
||||
'osascript',
|
||||
['-e', 'POSIX path of (choose folder with prompt "Select a code folder to link")'],
|
||||
{ timeout: 120_000 },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve(null);
|
||||
const p = stdout.trim().replace(/\/$/, '');
|
||||
resolve(p || null);
|
||||
},
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
execFile(
|
||||
'zenity',
|
||||
['--file-selection', '--directory', '--title=Select a code folder to link'],
|
||||
{ timeout: 120_000 },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve(null);
|
||||
const p = stdout.trim();
|
||||
resolve(p || null);
|
||||
},
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const ps = "Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select a code folder to link'; if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }";
|
||||
execFile('powershell.exe', ['-NoProfile', '-Command', ps], { timeout: 120_000 }, (err, stdout) => {
|
||||
if (err) return resolve(null);
|
||||
const p = stdout.trim();
|
||||
resolve(p || null);
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ApiErrorCode} code
|
||||
* @param {string} message
|
||||
|
|
@ -912,7 +951,18 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
skillId: skillId ?? null,
|
||||
designSystemId: designSystemId ?? null,
|
||||
pendingPrompt: pendingPrompt || null,
|
||||
metadata: metadata && typeof metadata === 'object' ? metadata : null,
|
||||
metadata:
|
||||
metadata && typeof metadata === 'object'
|
||||
? {
|
||||
...metadata,
|
||||
...(Array.isArray(metadata.linkedDirs)
|
||||
? (() => {
|
||||
const v = validateLinkedDirs(metadata.linkedDirs);
|
||||
return v.error ? {} : { linkedDirs: v.dirs };
|
||||
})()
|
||||
: {}),
|
||||
}
|
||||
: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
@ -1040,6 +1090,13 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
app.patch('/api/projects/:id', (req, res) => {
|
||||
try {
|
||||
const patch = req.body || {};
|
||||
if (patch.metadata?.linkedDirs) {
|
||||
const validated = validateLinkedDirs(patch.metadata.linkedDirs);
|
||||
if (validated.error) {
|
||||
return sendApiError(res, 400, 'INVALID_LINKED_DIR', validated.error);
|
||||
}
|
||||
patch.metadata.linkedDirs = validated.dirs;
|
||||
}
|
||||
const project = updateProject(db, req.params.id, patch);
|
||||
if (!project)
|
||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
||||
|
|
@ -2229,6 +2286,21 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
}
|
||||
});
|
||||
|
||||
// Native OS folder picker dialog. Returns { path: string | null }.
|
||||
app.post('/api/dialog/open-folder', async (req, res) => {
|
||||
if (!isLocalSameOrigin(req, resolvedPort)) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
try {
|
||||
const selected = await openNativeFolderDialog();
|
||||
res.json({ path: selected });
|
||||
} catch (err) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: String(err && err.message ? err.message : err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/media/generate', async (req, res) => {
|
||||
if (!isLocalSameOrigin(req, resolvedPort)) {
|
||||
return res.status(403).json({
|
||||
|
|
@ -2618,9 +2690,23 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
.map((f) => `- ${f.name}`)
|
||||
.join('\n')}`
|
||||
: '\nThis folder is empty. Choose a clear, descriptive filename for whatever you create.';
|
||||
const projectRecord =
|
||||
typeof projectId === 'string' && projectId
|
||||
? getProject(db, projectId)
|
||||
: null;
|
||||
const linkedDirs = (() => {
|
||||
if (!Array.isArray(projectRecord?.metadata?.linkedDirs)) return [];
|
||||
const v = validateLinkedDirs(projectRecord.metadata.linkedDirs);
|
||||
return v.dirs ?? [];
|
||||
})();
|
||||
const cwdHint = cwd
|
||||
? `\n\nYour working directory: ${cwd}\nWrite project files relative to it (e.g. \`index.html\`, \`assets/x.png\`). The user can browse those files in real time.${filesListBlock}`
|
||||
: '';
|
||||
const linkedDirsHint = linkedDirs.length > 0
|
||||
? `\n\nLinked code folders (read-only reference code the user wants you to see):\n${
|
||||
linkedDirs.map((d) => `- \`${d}\``).join('\n')
|
||||
}`
|
||||
: '';
|
||||
const attachmentHint = safeAttachments.length
|
||||
? `\n\nAttached project files: ${safeAttachments.map((p) => `\`${p}\``).join(', ')}`
|
||||
: '';
|
||||
|
|
@ -2637,10 +2723,12 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
.join('\n\n---\n\n');
|
||||
const composed = [
|
||||
instructionPrompt
|
||||
? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}\n\n---\n`
|
||||
? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}${linkedDirsHint}\n\n---\n`
|
||||
: cwdHint
|
||||
? `# Instructions${cwdHint}\n\n---\n`
|
||||
: '',
|
||||
? `# Instructions${cwdHint}${linkedDirsHint}\n\n---\n`
|
||||
: linkedDirsHint
|
||||
? `# Instructions${linkedDirsHint}\n\n---\n`
|
||||
: '',
|
||||
`# User request\n\n${message || '(No extra typed instruction.)'}${attachmentHint}${commentHint}`,
|
||||
safeImages.length
|
||||
? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}`
|
||||
|
|
@ -2692,9 +2780,11 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
// no-project runs (packaged daemons / service launches do not start
|
||||
// their working directory from the workspace root).
|
||||
const effectiveCwd = cwd ?? PROJECT_ROOT;
|
||||
const extraAllowedDirs = [SKILLS_DIR, DESIGN_SYSTEMS_DIR].filter((d) =>
|
||||
fs.existsSync(d),
|
||||
);
|
||||
const extraAllowedDirs = [
|
||||
SKILLS_DIR,
|
||||
DESIGN_SYSTEMS_DIR,
|
||||
...linkedDirs,
|
||||
].filter((d) => fs.existsSync(d));
|
||||
// Per-agent model + reasoning the user picked in the model menu.
|
||||
// Trust the value when it matches the most recent /api/agents listing
|
||||
// (live or fallback). Otherwise allow it through if it passes a
|
||||
|
|
|
|||
121
apps/daemon/tests/linked-dirs.test.ts
Normal file
121
apps/daemon/tests/linked-dirs.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { test } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { validateLinkedDirs } from '../src/linked-dirs.js';
|
||||
|
||||
/** Resolve macOS /var -> /private/var etc. so assertions match realpathSync. */
|
||||
function real(p: string): string {
|
||||
try { return realpathSync(p); } catch { return p; }
|
||||
}
|
||||
|
||||
test('rejects non-array input', () => {
|
||||
assert.equal(validateLinkedDirs('not-array').error, 'linkedDirs must be an array');
|
||||
assert.equal(validateLinkedDirs(null).error, 'linkedDirs must be an array');
|
||||
});
|
||||
|
||||
test('rejects non-string entries', () => {
|
||||
assert.equal(validateLinkedDirs([123]).error, 'each linked dir must be a non-empty string');
|
||||
assert.equal(validateLinkedDirs(['']).error, 'each linked dir must be a non-empty string');
|
||||
});
|
||||
|
||||
test('rejects relative paths', () => {
|
||||
const result = validateLinkedDirs(['relative/path']);
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error.includes('absolute path'));
|
||||
});
|
||||
|
||||
test('rejects non-existent directories', () => {
|
||||
const result = validateLinkedDirs(['/no/such/directory/ever']);
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error!.includes('does not exist'));
|
||||
});
|
||||
|
||||
test('rejects files (non-directories)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
|
||||
const file = join(tmp, 'file.txt');
|
||||
writeFileSync(file, 'test');
|
||||
try {
|
||||
const result = validateLinkedDirs([file]);
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error!.includes('not a directory'));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects filesystem root', () => {
|
||||
const result = validateLinkedDirs(['/']);
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error.includes('system directory'));
|
||||
});
|
||||
|
||||
test('rejects blocked system directories', () => {
|
||||
const result = validateLinkedDirs([real('/etc')]);
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error.includes('system directory'));
|
||||
});
|
||||
|
||||
test('rejects symlink pointing to blocked directory', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
|
||||
const link = join(tmp, 'etc-link');
|
||||
try {
|
||||
symlinkSync('/etc', link);
|
||||
const result = validateLinkedDirs([link]);
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error.includes('system directory'));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts valid directories and normalizes paths', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
|
||||
try {
|
||||
const result = validateLinkedDirs([tmp]);
|
||||
assert.ok(!result.error);
|
||||
assert.deepEqual(result.dirs, [real(tmp)]);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('deduplicates entries', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
|
||||
try {
|
||||
const result = validateLinkedDirs([tmp, tmp]);
|
||||
assert.ok(!result.error);
|
||||
assert.equal(result.dirs!.length, 1);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolves and normalizes paths', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
|
||||
const inner = join(tmp, 'inner');
|
||||
mkdirSync(inner);
|
||||
try {
|
||||
const result = validateLinkedDirs([join(tmp, 'inner', '..') + '/']);
|
||||
assert.ok(!result.error);
|
||||
assert.deepEqual(result.dirs, [real(tmp)]);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolves symlinks to real paths', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
|
||||
const inner = join(tmp, 'inner');
|
||||
const link = join(tmp, 'link');
|
||||
mkdirSync(inner);
|
||||
try {
|
||||
symlinkSync(inner, link);
|
||||
const result = validateLinkedDirs([link]);
|
||||
assert.ok(!result.error);
|
||||
assert.deepEqual(result.dirs, [real(inner)]);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -8,8 +8,9 @@ import {
|
|||
} from "react";
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import { projectRawUrl, uploadProjectFiles } from "../providers/registry";
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile } from "../types";
|
||||
import { projectRawUrl, uploadProjectFiles, openFolderDialog } from "../providers/registry";
|
||||
import { patchProject } from "../state/projects";
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata } from "../types";
|
||||
import { Icon } from "./Icon";
|
||||
import { BUILT_IN_PETS, CUSTOM_PET_ID, resolveActivePet } from "./pet/pets";
|
||||
|
||||
|
|
@ -57,6 +58,8 @@ interface Props {
|
|||
onAdoptPet?: (petId: string) => void;
|
||||
onTogglePet?: () => void;
|
||||
onOpenPetSettings?: () => void;
|
||||
projectMetadata?: ProjectMetadata;
|
||||
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
|
||||
}
|
||||
|
||||
// Imperative handle so ancestors (e.g. example chips in ChatPane) can
|
||||
|
|
@ -92,6 +95,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
onAdoptPet,
|
||||
onTogglePet,
|
||||
onOpenPetSettings,
|
||||
projectMetadata,
|
||||
onProjectMetadataChange,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
|
|
@ -123,6 +128,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const petMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const petTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const petEnabled = Boolean(onAdoptPet && onTogglePet);
|
||||
const linkedDirs = projectMetadata?.linkedDirs ?? [];
|
||||
// initialDraft is only honored on the first non-empty value the parent
|
||||
// hands us. After we seed once, the composer is fully under user control
|
||||
// — re-renders that pass the same prompt back must not reseed. If the
|
||||
|
|
@ -394,6 +400,28 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
if (files.length > 0) void uploadFiles(files);
|
||||
}
|
||||
|
||||
async function handleLinkFolder() {
|
||||
setImportOpen(false);
|
||||
if (!projectId) return;
|
||||
const selected = await openFolderDialog();
|
||||
if (!selected) return;
|
||||
const base = projectMetadata ?? { kind: 'prototype' as const };
|
||||
const existing = base.linkedDirs ?? [];
|
||||
if (existing.includes(selected)) return;
|
||||
const metadata: ProjectMetadata = { ...base, linkedDirs: [...existing, selected] };
|
||||
const result = await patchProject(projectId, { metadata });
|
||||
if (result?.metadata) onProjectMetadataChange?.(result.metadata);
|
||||
}
|
||||
|
||||
async function handleUnlinkFolder(dir: string) {
|
||||
if (!projectId) return;
|
||||
const base = projectMetadata ?? { kind: 'prototype' as const };
|
||||
const existing = base.linkedDirs ?? [];
|
||||
const metadata: ProjectMetadata = { ...base, linkedDirs: existing.filter((d) => d !== dir) };
|
||||
const result = await patchProject(projectId, { metadata });
|
||||
if (result?.metadata) onProjectMetadataChange?.(result.metadata);
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const value = e.target.value;
|
||||
const cursor = e.target.selectionStart;
|
||||
|
|
@ -504,6 +532,26 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
{linkedDirs.length > 0 ? (
|
||||
<div className="linked-dirs-row" data-testid="linked-dirs">
|
||||
{linkedDirs.map((dir) => (
|
||||
<div key={dir} className="linked-dir-chip">
|
||||
<Icon name="folder" size={13} />
|
||||
<span className="linked-dir-name" title={dir}>
|
||||
{dir.split('/').pop() || dir}
|
||||
</span>
|
||||
<button
|
||||
className="staged-remove"
|
||||
onClick={() => handleUnlinkFolder(dir)}
|
||||
title={t('chat.linkedFolderRemoveAria', { path: dir })}
|
||||
aria-label={t('chat.linkedFolderRemoveAria', { path: dir })}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{commentAttachments.length > 0 ? (
|
||||
<StagedCommentAttachments
|
||||
attachments={commentAttachments}
|
||||
|
|
@ -628,7 +676,13 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
<ImportItem icon="upload" label={t('chat.importFig')} t={t} />
|
||||
<ImportItem icon="link" label={t('chat.importGitHub')} t={t} />
|
||||
<ImportItem icon="grid" label={t('chat.importWeb')} t={t} />
|
||||
<ImportItem icon="folder" label={t('chat.importFolder')} t={t} />
|
||||
<ImportItem
|
||||
icon="folder"
|
||||
label={t('chat.importFolder')}
|
||||
t={t}
|
||||
enabled
|
||||
onClick={handleLinkFolder}
|
||||
/>
|
||||
<ImportItem
|
||||
icon="sparkles"
|
||||
label={t('chat.importSkills')}
|
||||
|
|
@ -840,26 +894,30 @@ function ImportItem({
|
|||
icon,
|
||||
label,
|
||||
t,
|
||||
enabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: "upload" | "link" | "grid" | "folder" | "sparkles" | "file";
|
||||
label: string;
|
||||
t: TranslateFn;
|
||||
enabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="composer-import-item"
|
||||
className={`composer-import-item${enabled ? ' composer-import-item-enabled' : ''}`}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
disabled
|
||||
title={t('chat.importComingSoon')}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
disabled={!enabled}
|
||||
title={enabled ? label : t('chat.importComingSoon')}
|
||||
onClick={enabled && onClick ? onClick : (e) => e.preventDefault()}
|
||||
>
|
||||
<span className="ico" aria-hidden>
|
||||
<Icon name={icon} size={14} />
|
||||
</span>
|
||||
<span className="composer-import-item-label">{label}</span>
|
||||
<span className="composer-import-item-soon">{t('chat.importSoon')}</span>
|
||||
{!enabled && <span className="composer-import-item-soon">{t('chat.importSoon')}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useT } from '../i18n';
|
|||
import type { Dict } from '../i18n/types';
|
||||
import { projectRawUrl } from '../providers/registry';
|
||||
import type { TodoItem } from '../runtime/todos';
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile } from '../types';
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata } from '../types';
|
||||
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
|
||||
import { commentsToAttachments, simplePositionLabel } from '../comments';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
|
|
@ -88,6 +88,8 @@ interface Props {
|
|||
onAdoptPet?: (petId: string) => void;
|
||||
onTogglePet?: () => void;
|
||||
onOpenPetSettings?: () => void;
|
||||
projectMetadata?: ProjectMetadata;
|
||||
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
|
||||
}
|
||||
|
||||
type Tab = 'chat' | 'comments';
|
||||
|
|
@ -122,6 +124,8 @@ export function ChatPane({
|
|||
onAdoptPet,
|
||||
onTogglePet,
|
||||
onOpenPetSettings,
|
||||
projectMetadata,
|
||||
onProjectMetadataChange,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const logRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -438,6 +442,8 @@ export function ChatPane({
|
|||
onAdoptPet={onAdoptPet}
|
||||
onTogglePet={onTogglePet}
|
||||
onOpenPetSettings={onOpenPetSettings}
|
||||
projectMetadata={projectMetadata}
|
||||
onProjectMetadataChange={onProjectMetadataChange}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1430,6 +1430,10 @@ export function ProjectView({
|
|||
onAdoptPet={onAdoptPetInline}
|
||||
onTogglePet={onTogglePet}
|
||||
onOpenPetSettings={onOpenPetSettings}
|
||||
projectMetadata={project.metadata}
|
||||
onProjectMetadataChange={(metadata) => {
|
||||
onProjectChange({ ...project, metadata });
|
||||
}}
|
||||
/>
|
||||
<FileWorkspace
|
||||
projectId={project.id}
|
||||
|
|
|
|||
|
|
@ -397,7 +397,11 @@ export const ar: Dict = {
|
|||
'chat.importWeb': 'جلب عنصر ويب',
|
||||
'chat.importFolder': 'ربط مجلد كود',
|
||||
'chat.importSkills': 'المهارات وأنظمة التصميم',
|
||||
'chat.importProject': 'مرجع لمشروع آخر',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'إزالة المجلد المرتبط {path}',
|
||||
'chat.linkedFolderNotFound': 'المجلد غير موجود',
|
||||
'chat.linkedFolderAlready': 'هذا المجلد مرتبط بالفعل',
|
||||
'chat.linkedFolderPickError': 'تعذر فتح منتقي المجلدات',
|
||||
'chat.send': 'إرسال',
|
||||
'chat.stop': 'إيقاف',
|
||||
'chat.removeAria': 'إزالة {name}',
|
||||
|
|
|
|||
|
|
@ -397,7 +397,11 @@ export const de: Dict = {
|
|||
'chat.importWeb': 'Webelement erfassen',
|
||||
'chat.importFolder': 'Code-Ordner verknüpfen',
|
||||
'chat.importSkills': 'Skills und Designsysteme',
|
||||
'chat.importProject': 'Anderes Projekt referenzieren',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Verknüpften Ordner {path} entfernen',
|
||||
'chat.linkedFolderNotFound': 'Ordner existiert nicht',
|
||||
'chat.linkedFolderAlready': 'Dieser Ordner ist bereits verknüpft',
|
||||
'chat.linkedFolderPickError': 'Ordnerauswahl konnte nicht geöffnet werden',
|
||||
'chat.send': 'Senden',
|
||||
'chat.stop': 'Stoppen',
|
||||
'chat.removeAria': '{name} entfernen',
|
||||
|
|
|
|||
|
|
@ -398,6 +398,10 @@ export const en: Dict = {
|
|||
'chat.importFolder': 'Link code folder',
|
||||
'chat.importSkills': 'Skills and design systems',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Remove linked folder {path}',
|
||||
'chat.linkedFolderNotFound': 'Folder does not exist',
|
||||
'chat.linkedFolderAlready': 'This folder is already linked',
|
||||
'chat.linkedFolderPickError': 'Could not open folder picker',
|
||||
'chat.send': 'Send',
|
||||
'chat.stop': 'Stop',
|
||||
'chat.removeAria': 'Remove {name}',
|
||||
|
|
|
|||
|
|
@ -398,7 +398,11 @@ export const esES: Dict = {
|
|||
'chat.importWeb': 'Capturar elemento web',
|
||||
'chat.importFolder': 'Vincular carpeta de código',
|
||||
'chat.importSkills': 'Skills y sistemas de diseño',
|
||||
'chat.importProject': 'Referenciar otro proyecto',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Eliminar carpeta vinculada {path}',
|
||||
'chat.linkedFolderNotFound': 'La carpeta no existe',
|
||||
'chat.linkedFolderAlready': 'Esta carpeta ya está vinculada',
|
||||
'chat.linkedFolderPickError': 'No se pudo abrir el selector de carpetas',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.stop': 'Detener',
|
||||
'chat.removeAria': 'Quitar {name}',
|
||||
|
|
|
|||
|
|
@ -397,7 +397,11 @@ export const fa: Dict = {
|
|||
'chat.importWeb': 'گرفتن عنصر وب',
|
||||
'chat.importFolder': 'لینک کردن پوشه کد',
|
||||
'chat.importSkills': 'مهارتها و سیستمهای طراحی',
|
||||
'chat.importProject': 'مرجع یک پروژه دیگر',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'حذف پوشه لینک شده {path}',
|
||||
'chat.linkedFolderNotFound': 'پوشه وجود ندارد',
|
||||
'chat.linkedFolderAlready': 'این پوشه قبلاً لینک شده است',
|
||||
'chat.linkedFolderPickError': 'انتخابگر پوشه باز نشد',
|
||||
'chat.send': 'ارسال',
|
||||
'chat.stop': 'توقف',
|
||||
'chat.removeAria': 'حذف {name}',
|
||||
|
|
|
|||
|
|
@ -398,6 +398,10 @@ export const fr: Dict = {
|
|||
'chat.importFolder': 'Lier un dossier de code',
|
||||
'chat.importSkills': 'Compétences et design systems',
|
||||
'chat.importProject': 'Référencer un autre projet',
|
||||
'chat.linkedFolderRemoveAria': 'Supprimer le dossier lié {path}',
|
||||
'chat.linkedFolderNotFound': 'Le dossier n\'existe pas',
|
||||
'chat.linkedFolderAlready': 'Ce dossier est déjà lié',
|
||||
'chat.linkedFolderPickError': 'Impossible d\'ouvrir le sélecteur de dossier',
|
||||
'chat.send': 'Envoyer',
|
||||
'chat.stop': 'Arrêter',
|
||||
'chat.removeAria': 'Retirer {name}',
|
||||
|
|
|
|||
|
|
@ -397,7 +397,11 @@ export const hu: Dict = {
|
|||
'chat.importWeb': 'Webelem mentése',
|
||||
'chat.importFolder': 'Kódmappa hozzákapcsolása',
|
||||
'chat.importSkills': 'Skillek és designrendszerek',
|
||||
'chat.importProject': 'Hivatkozás másik projektre',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Hozzákapcsolt mappa eltávolítása {path}',
|
||||
'chat.linkedFolderNotFound': 'A mappa nem létezik',
|
||||
'chat.linkedFolderAlready': 'Ez a mappa már hozzá van kapcsolva',
|
||||
'chat.linkedFolderPickError': 'Nem sikerült megnyitni a mappaválasztót',
|
||||
'chat.send': 'Küldés',
|
||||
'chat.stop': 'Leállítás',
|
||||
'chat.removeAria': '{name} eltávolítása',
|
||||
|
|
|
|||
|
|
@ -396,7 +396,11 @@ export const ja: Dict = {
|
|||
'chat.importWeb': 'Web 要素を取得',
|
||||
'chat.importFolder': 'コードフォルダーをリンク',
|
||||
'chat.importSkills': 'スキルとデザインシステム',
|
||||
'chat.importProject': '別のプロジェクトを参照',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'リンクされたフォルダー {path} を削除',
|
||||
'chat.linkedFolderNotFound': 'フォルダーが存在しません',
|
||||
'chat.linkedFolderAlready': 'このフォルダーは既にリンクされています',
|
||||
'chat.linkedFolderPickError': 'フォルダー選択を開けません',
|
||||
'chat.send': '送信',
|
||||
'chat.stop': '停止',
|
||||
'chat.removeAria': '{name} を削除',
|
||||
|
|
|
|||
|
|
@ -397,7 +397,11 @@ export const ko: Dict = {
|
|||
'chat.importWeb': '웹 요소 가져오기',
|
||||
'chat.importFolder': '코드 폴더 연결',
|
||||
'chat.importSkills': '스킬 및 디자인 시스템',
|
||||
'chat.importProject': '다른 프로젝트 참조',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': '연결된 폴더 {path} 제거',
|
||||
'chat.linkedFolderNotFound': '폴더가 존재하지 않습니다',
|
||||
'chat.linkedFolderAlready': '이미 연결된 폴더입니다',
|
||||
'chat.linkedFolderPickError': '폴더 선택기를 열 수 없습니다',
|
||||
'chat.send': '전송',
|
||||
'chat.stop': '중지',
|
||||
'chat.removeAria': '{name} 제거',
|
||||
|
|
|
|||
|
|
@ -397,7 +397,11 @@ export const pl: Dict = {
|
|||
'chat.importWeb': 'Pobierz element webowy',
|
||||
'chat.importFolder': 'Połącz folder z kodem',
|
||||
'chat.importSkills': 'Umiejętności i systemy projektowania',
|
||||
'chat.importProject': 'Referencja do innego projektu',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Usuń połączony folder {path}',
|
||||
'chat.linkedFolderNotFound': 'Folder nie istnieje',
|
||||
'chat.linkedFolderAlready': 'Ten folder jest już połączony',
|
||||
'chat.linkedFolderPickError': 'Nie można otworzyć wyboru folderu',
|
||||
'chat.send': 'Wyślij',
|
||||
'chat.stop': 'Zatrzymaj',
|
||||
'chat.removeAria': 'Usuń {name}',
|
||||
|
|
|
|||
|
|
@ -396,7 +396,11 @@ export const ptBR: Dict = {
|
|||
'chat.importWeb': 'Capturar elemento web',
|
||||
'chat.importFolder': 'Vincular pasta de código',
|
||||
'chat.importSkills': 'Skills e sistemas de design',
|
||||
'chat.importProject': 'Referenciar outro projeto',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Remover pasta vinculada {path}',
|
||||
'chat.linkedFolderNotFound': 'A pasta não existe',
|
||||
'chat.linkedFolderAlready': 'Esta pasta já está vinculada',
|
||||
'chat.linkedFolderPickError': 'Não foi possível abrir o seletor de pasta',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.stop': 'Parar',
|
||||
'chat.removeAria': 'Remover {name}',
|
||||
|
|
|
|||
|
|
@ -396,7 +396,11 @@ export const ru: Dict = {
|
|||
'chat.importWeb': 'Захватить веб-элемент',
|
||||
'chat.importFolder': 'Ссылка на папку с кодом',
|
||||
'chat.importSkills': 'Навыки и дизайн-системы',
|
||||
'chat.importProject': 'Ссылка на другой проект',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Удалить связанную папку {path}',
|
||||
'chat.linkedFolderNotFound': 'Папка не существует',
|
||||
'chat.linkedFolderAlready': 'Эта папка уже связана',
|
||||
'chat.linkedFolderPickError': 'Не удалось открыть выбор папки',
|
||||
'chat.send': 'Отправить',
|
||||
'chat.stop': 'Остановить',
|
||||
'chat.removeAria': 'Удалить {name}',
|
||||
|
|
|
|||
|
|
@ -396,7 +396,11 @@ export const tr: Dict = {
|
|||
'chat.importWeb': 'Bir web elementi',
|
||||
'chat.importFolder': 'Kod klasörünü bağlantıla',
|
||||
'chat.importSkills': 'Yetenekler ve tasarım sistemleri',
|
||||
'chat.importProject': 'Bir başka projeyi referans al',
|
||||
'chat.importProject': 'Reference another project',
|
||||
'chat.linkedFolderRemoveAria': 'Bağlantılı klasörü kaldır {path}',
|
||||
'chat.linkedFolderNotFound': 'Klasör mevcut değil',
|
||||
'chat.linkedFolderAlready': 'Bu klasör zaten bağlantılı',
|
||||
'chat.linkedFolderPickError': 'Klasör seçici açılamadı',
|
||||
'chat.send': 'Gönder',
|
||||
'chat.stop': 'Durdur',
|
||||
'chat.removeAria': '{name}’ı sil',
|
||||
|
|
|
|||
|
|
@ -399,6 +399,10 @@ export const uk: Dict = {
|
|||
'chat.importFolder': 'Пов\'язати папку коду',
|
||||
'chat.importSkills': 'Навички та системи дизайну',
|
||||
'chat.importProject': 'Посилання на інший проект',
|
||||
'chat.linkedFolderRemoveAria': 'Видалити пов\'язану папку {path}',
|
||||
'chat.linkedFolderNotFound': 'Папка не існує',
|
||||
'chat.linkedFolderAlready': 'Ця папка вже пов\'язана',
|
||||
'chat.linkedFolderPickError': 'Не вдалося відкрити вибір папки',
|
||||
'chat.send': 'Надіслати',
|
||||
'chat.stop': 'Зупинити',
|
||||
'chat.removeAria': 'Видалити {name}',
|
||||
|
|
|
|||
|
|
@ -390,6 +390,10 @@ export const zhCN: Dict = {
|
|||
'chat.importFolder': '关联代码目录',
|
||||
'chat.importSkills': '技能与设计体系',
|
||||
'chat.importProject': '引用其它项目',
|
||||
'chat.linkedFolderRemoveAria': '移除关联文件夹 {path}',
|
||||
'chat.linkedFolderNotFound': '文件夹不存在',
|
||||
'chat.linkedFolderAlready': '该文件夹已关联',
|
||||
'chat.linkedFolderPickError': '无法打开文件夹选择器',
|
||||
'chat.send': '发送',
|
||||
'chat.stop': '停止',
|
||||
'chat.removeAria': '移除 {name}',
|
||||
|
|
|
|||
|
|
@ -390,6 +390,10 @@ export const zhTW: Dict = {
|
|||
'chat.importFolder': '關聯程式碼目錄',
|
||||
'chat.importSkills': '技能與設計系統',
|
||||
'chat.importProject': '引用其它專案',
|
||||
'chat.linkedFolderRemoveAria': '移除關聯資料夾 {path}',
|
||||
'chat.linkedFolderNotFound': '資料夾不存在',
|
||||
'chat.linkedFolderAlready': '該資料夾已關聯',
|
||||
'chat.linkedFolderPickError': '無法開啟資料夾選擇器',
|
||||
'chat.send': '傳送',
|
||||
'chat.stop': '停止',
|
||||
'chat.removeAria': '移除 {name}',
|
||||
|
|
|
|||
|
|
@ -444,6 +444,10 @@ export interface Dict {
|
|||
'chat.importFolder': string;
|
||||
'chat.importSkills': string;
|
||||
'chat.importProject': string;
|
||||
'chat.linkedFolderRemoveAria': string;
|
||||
'chat.linkedFolderNotFound': string;
|
||||
'chat.linkedFolderAlready': string;
|
||||
'chat.linkedFolderPickError': string;
|
||||
'chat.send': string;
|
||||
'chat.stop': string;
|
||||
'chat.removeAria': string;
|
||||
|
|
|
|||
|
|
@ -882,6 +882,39 @@ code {
|
|||
}
|
||||
.staged-remove:hover { color: var(--red); background: var(--red-bg); }
|
||||
|
||||
.linked-dirs-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.linked-dir-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
max-width: 220px;
|
||||
}
|
||||
.linked-dir-chip svg { flex-shrink: 0; color: var(--text-muted); }
|
||||
.linked-dir-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-import-item-enabled {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.composer-import-item-enabled:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-attachments {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
|
|
|||
|
|
@ -628,6 +628,17 @@ export async function deleteProjectFile(
|
|||
}
|
||||
}
|
||||
|
||||
export async function openFolderDialog(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch('/api/dialog/open-folder', { method: 'POST' });
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return typeof data.path === 'string' && data.path.length > 0 ? data.path : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDesignSystemPreview(id: string): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}/preview`);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export interface ProjectMetadata {
|
|||
// New Project panel. Treated by the system-prompt composer as a stylistic
|
||||
// and structural reference for the generation request.
|
||||
promptTemplate?: PromptTemplateMetadata;
|
||||
// Absolute paths to local code folders the agent can read via --add-dir.
|
||||
linkedDirs?: string[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
|
|
|||
Loading…
Reference in a new issue