feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createBrowserSessionProfile, startBrowserSessionLogin,
|
||||
saveBrowserSession, cancelBrowserSession,
|
||||
type BrowserSessionProfile,
|
||||
} from '../../api';
|
||||
import { usePictureInPicture } from '../../lib/usePictureInPicture.js';
|
||||
import { PipButton } from '../browser/PipButton.js';
|
||||
|
||||
type Phase = 'form' | 'logging-in' | 'saving' | 'done' | 'error';
|
||||
|
||||
interface Props {
|
||||
existingProfile?: BrowserSessionProfile | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [phase, setPhase] = useState<Phase>('form');
|
||||
const [label, setLabel] = useState(existingProfile?.label ?? '');
|
||||
const [startUrl, setStartUrl] = useState(existingProfile?.startUrl ?? '');
|
||||
const [loggedInSelector, setLoggedInSelector] = useState(existingProfile?.loggedInSelector ?? '');
|
||||
const [loginUrl, setLoginUrl] = useState(existingProfile?.loginUrlPatterns?.[0] ?? '');
|
||||
const [profileId, setProfileId] = useState<number | null>(existingProfile?.id ?? null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [novncPath, setNovncPath] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pip = usePictureInPicture(novncPath, label ? `noVNC — ログイン: ${label}` : 'noVNC — ログイン');
|
||||
|
||||
async function startLogin() {
|
||||
setError(null);
|
||||
try {
|
||||
let pid = profileId;
|
||||
if (!pid) {
|
||||
const created = await createBrowserSessionProfile({
|
||||
label,
|
||||
startUrl,
|
||||
matchPatterns: [],
|
||||
storageOrigins: [new URL(startUrl).origin],
|
||||
loggedInSelector: loggedInSelector || undefined,
|
||||
loginUrlPatterns: loginUrl ? [loginUrl] : [],
|
||||
});
|
||||
pid = created.id;
|
||||
setProfileId(pid);
|
||||
}
|
||||
const r = await startBrowserSessionLogin(pid);
|
||||
setSessionId(r.sessionId);
|
||||
setNovncPath(r.novncPath);
|
||||
setPhase('logging-in');
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNow() {
|
||||
if (!profileId || !sessionId) return;
|
||||
setPhase('saving');
|
||||
try {
|
||||
await saveBrowserSession(profileId, sessionId);
|
||||
qc.invalidateQueries({ queryKey: ['browser-session-profiles'] });
|
||||
setPhase('done');
|
||||
setTimeout(onClose, 800);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (profileId && sessionId) await cancelBrowserSession(profileId, sessionId).catch(() => {});
|
||||
onClose();
|
||||
}
|
||||
|
||||
// The remote noVNC content is rendered at the Xvfb native resolution
|
||||
// (1280x720, see src/engine/browser-session.ts). At the form-phase 640px
|
||||
// dialog width the iframe scales down to ~50%, which feels cramped while
|
||||
// the user is actually logging in. Expand the dialog to ~1320x860 once we
|
||||
// enter the login phase so the iframe can show 1:1.
|
||||
const inLogin = phase === 'logging-in';
|
||||
const dialogSize = inLogin
|
||||
? 'w-[1320px] h-[860px] max-w-[95vw] max-h-[95vh]'
|
||||
: 'w-[640px] max-w-[95vw] max-h-[90vh]';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className={`bg-white rounded-lg shadow-xl ${dialogSize} overflow-hidden flex flex-col`}>
|
||||
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">
|
||||
{existingProfile ? `再ログイン: ${existingProfile.label}` : 'ブラウザセッションを追加'}
|
||||
</h3>
|
||||
<button onClick={cancel} className="text-slate-400 hover:text-slate-700 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{phase === 'form' && (
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-700 mb-1">ラベル</label>
|
||||
<input value={label} onChange={e => setLabel(e.target.value)}
|
||||
disabled={!!existingProfile}
|
||||
placeholder="My Twitter"
|
||||
className="w-full h-8 px-2 text-xs border border-hairline rounded-md disabled:bg-slate-50 disabled:text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-700 mb-1">開始 URL</label>
|
||||
<input value={startUrl} onChange={e => setStartUrl(e.target.value)}
|
||||
placeholder="https://twitter.com/home"
|
||||
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-700 mb-1">ログイン済みセレクター(任意)</label>
|
||||
<input value={loggedInSelector} onChange={e => setLoggedInSelector(e.target.value)}
|
||||
placeholder='[data-testid="primaryColumn"]'
|
||||
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-700 mb-1">ログイン URL パターン(任意)</label>
|
||||
<input value={loginUrl} onChange={e => setLoginUrl(e.target.value)}
|
||||
placeholder="https://twitter.com/i/flow/login**"
|
||||
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
|
||||
</div>
|
||||
{error && <div className="text-xs text-rose-600">{error}</div>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={cancel} className="text-xs px-3 py-1.5 rounded-md hover:bg-surface">キャンセル</button>
|
||||
<button disabled={!label || !startUrl} onClick={startLogin}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:bg-slate-300">
|
||||
ログインウィンドウを開く
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'logging-in' && novncPath && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 min-h-[420px] bg-black">
|
||||
{pip.isOpen ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-slate-300">
|
||||
PiP ウィンドウで表示中。閉じるとここに戻ります。
|
||||
</div>
|
||||
) : (
|
||||
<iframe src={novncPath} title="login" className="w-full h-full" allow="clipboard-read; clipboard-write" />
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-hairline flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">上のウィンドウでログインし、完了したら「保存」をクリックしてください。</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
<PipButton pip={pip} />
|
||||
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface">キャンセル</button>
|
||||
<button onClick={saveNow} className="px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'saving' && <div className="p-6 text-center text-xs text-slate-500">保存中…</div>}
|
||||
{phase === 'done' && <div className="p-6 text-center text-xs text-emerald-600">保存しました。</div>}
|
||||
{phase === 'error' && (
|
||||
<div className="p-6 space-y-3 text-center">
|
||||
<div className="text-xs text-rose-600">{error}</div>
|
||||
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface text-xs">閉じる</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MonacoFileEditor } from './MonacoFileEditor';
|
||||
|
||||
interface AgentsMdResponse {
|
||||
exists: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function fetchAgentsMd(): Promise<AgentsMdResponse> {
|
||||
const res = await fetch('/api/users/me/agents-md', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json() as Promise<AgentsMdResponse>;
|
||||
}
|
||||
|
||||
async function saveAgentsMd(content: string): Promise<void> {
|
||||
const res = await fetch('/api/users/me/agents-md', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body: content,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAgentsMd(): Promise<void> {
|
||||
const res = await fetch('/api/users/me/agents-md', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
interface AgentsMdPanelProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function AgentsMdPanel({ onDirtyChange }: AgentsMdPanelProps) {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['agents-md'],
|
||||
queryFn: fetchAgentsMd,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: saveAgentsMd,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['agents-md'] }),
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: deleteAgentsMd,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['agents-md'] }),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-6 text-[13px] text-slate-400">Loading…</div>;
|
||||
if (error) return <div className="p-6 text-[13px] text-red-500">読み込みに失敗しました: {String(error)}</div>;
|
||||
|
||||
const content = data?.content ?? '';
|
||||
const byteSize = new TextEncoder().encode(content).length;
|
||||
|
||||
const handleSave = async (next: string) => {
|
||||
await save.mutateAsync(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[13px] font-semibold text-slate-900">AGENTS.md</h2>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
タスク実行時に system prompt へ自動注入される、あなた専用の永続的な指示。 最大 64KB。
|
||||
</p>
|
||||
</div>
|
||||
{data?.exists && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xs text-red-600 hover:text-red-800 underline"
|
||||
onClick={() => {
|
||||
if (window.confirm('AGENTS.md を削除しますか?')) del.mutate();
|
||||
}}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<MonacoFileEditor
|
||||
subdir="agents-md"
|
||||
filename="AGENTS.md"
|
||||
content={content}
|
||||
mtime=""
|
||||
size={byteSize}
|
||||
onSave={handleSave}
|
||||
onDirtyChange={onDirtyChange ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile,
|
||||
type BrowserSessionProfile,
|
||||
} from '../../api';
|
||||
import { AddBrowserSessionDialog } from './AddBrowserSessionDialog';
|
||||
|
||||
function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) {
|
||||
const map: Record<BrowserSessionProfile['status'], string> = {
|
||||
pending: 'bg-slate-200 text-slate-700',
|
||||
active: 'bg-emerald-100 text-emerald-700',
|
||||
expired: 'bg-amber-100 text-amber-800',
|
||||
revoked: 'bg-slate-200 text-slate-500',
|
||||
error: 'bg-rose-100 text-rose-700',
|
||||
};
|
||||
const labels: Record<BrowserSessionProfile['status'], string> = {
|
||||
pending: '保留中',
|
||||
active: '有効',
|
||||
expired: '期限切れ',
|
||||
revoked: '無効化',
|
||||
error: 'エラー',
|
||||
};
|
||||
return <span className={`inline-flex items-center rounded px-2 py-0.5 text-2xs font-medium ${map[status]}`}>{labels[status]}</span>;
|
||||
}
|
||||
|
||||
export function BrowserSessionsPanel() {
|
||||
const qc = useQueryClient();
|
||||
const { data: profiles = [], isLoading } = useQuery({
|
||||
queryKey: ['browser-session-profiles'],
|
||||
queryFn: listBrowserSessionProfiles,
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => deleteBrowserSessionProfile(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['browser-session-profiles'] }),
|
||||
});
|
||||
const test = useMutation({
|
||||
mutationFn: (id: number) => testBrowserSessionProfile(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['browser-session-profiles'] }),
|
||||
});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [reLoginProfileId, setReLoginProfileId] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-800">ブラウザセッション</h2>
|
||||
<button onClick={() => { setReLoginProfileId(null); setAdding(true); }}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:bg-accent-deep">
|
||||
サイトのセッションを追加
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
ログイン後の cookie / storageState をユーザーごとに暗号化して保存し、ブラウザマクロから{' '}
|
||||
<code className="font-mono text-2xs bg-slate-100 px-1 py-0.5 rounded">session_profile_id</code>{' '}
|
||||
で参照できるようにします。保存されたセッションは他のユーザーと共有されません。
|
||||
</p>
|
||||
|
||||
{isLoading && <div className="text-xs text-slate-500">読み込み中…</div>}
|
||||
|
||||
<div className="rounded-md border border-hairline divide-y divide-hairline">
|
||||
{profiles.length === 0 && !isLoading && (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-400">
|
||||
<div>保存済みセッションはまだありません。</div>
|
||||
<div className="mt-1 text-slate-400">上の「サイトのセッションを追加」ボタンから始めてください。</div>
|
||||
</div>
|
||||
)}
|
||||
{profiles.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-slate-800 truncate">{p.label}</span>
|
||||
<StatusPill status={p.status} />
|
||||
<span className="text-[10px] font-mono text-slate-400">id={p.id}</span>
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 truncate">{p.startUrl}</div>
|
||||
{p.lastError && <div className="text-2xs text-rose-600 truncate">{p.lastError}</div>}
|
||||
<div className="text-2xs text-slate-400">
|
||||
{p.lastSavedAt ? `保存: ${new Date(p.lastSavedAt).toLocaleString('ja-JP')}` : '未保存'}
|
||||
{p.lastUsedAt && ` · 最終使用: ${new Date(p.lastUsedAt).toLocaleString('ja-JP')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => test.mutate(p.id)} disabled={test.isPending}
|
||||
className="text-xs text-slate-700 hover:text-slate-900 px-2 py-1 rounded hover:bg-surface disabled:opacity-50">テスト</button>
|
||||
<button onClick={() => { setReLoginProfileId(p.id); setAdding(true); }}
|
||||
className="text-xs text-slate-700 hover:text-slate-900 px-2 py-1 rounded hover:bg-surface">再ログイン</button>
|
||||
<button onClick={() => { if (confirm(`${p.label} を削除しますか?`)) del.mutate(p.id); }}
|
||||
className="text-xs text-rose-600 hover:text-rose-800 px-2 py-1 rounded hover:bg-rose-50">削除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<AddBrowserSessionDialog
|
||||
existingProfile={reLoginProfileId ? profiles.find(p => p.id === reLoginProfileId) ?? null : null}
|
||||
onClose={() => { setAdding(false); setReLoginProfileId(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
// 'agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections' are virtual subdirs (not raw file editor directories)
|
||||
export type SubdirId = 'agents-md' | 'scripts' | 'browser-macros' | 'templates' | 'recordings' | 'trash' | 'memory' | 'browser-sessions' | 'mcp' | 'skills' | 'pets' | 'ssh-connections' | 'notes' | 'subscribed-notes';
|
||||
|
||||
/** True for subdirs that have actual files on disk */
|
||||
export const FILE_SUBDIRS: SubdirId[] = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'notes'];
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
mtime: string;
|
||||
}
|
||||
|
||||
export interface SubdirFiles {
|
||||
subdir: SubdirId;
|
||||
files: FileEntry[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface FileTreeProps {
|
||||
subdirData: SubdirFiles[];
|
||||
selectedSubdir: SubdirId | null;
|
||||
selectedFile: string | null;
|
||||
onSelectSubdir: (subdir: SubdirId) => void;
|
||||
onSelectFile: (subdir: SubdirId, file: string) => void;
|
||||
onDeleteFile: (subdir: SubdirId, file: string) => void;
|
||||
}
|
||||
|
||||
const SUBDIR_LABELS: Record<SubdirId, string> = {
|
||||
'agents-md': 'AGENTS.md',
|
||||
scripts: 'scripts',
|
||||
'browser-macros': 'browser-macros',
|
||||
templates: 'templates',
|
||||
recordings: 'recordings',
|
||||
trash: 'trash',
|
||||
memory: 'memory',
|
||||
'browser-sessions': 'browser-sessions',
|
||||
mcp: 'MCP',
|
||||
skills: 'Skills',
|
||||
pets: 'pets',
|
||||
'ssh-connections': 'ssh-connections',
|
||||
notes: 'Notes (共有)',
|
||||
'subscribed-notes': 'Subscribed Notes',
|
||||
};
|
||||
|
||||
const SUBDIR_ICONS: Record<SubdirId, string> = {
|
||||
'agents-md': '📖',
|
||||
scripts: '📜',
|
||||
'browser-macros': '🤖',
|
||||
templates: '📄',
|
||||
recordings: '🎬',
|
||||
trash: '🗑',
|
||||
memory: '🧠',
|
||||
'browser-sessions': '🌐',
|
||||
mcp: '🔌',
|
||||
skills: '📚',
|
||||
pets: '◉',
|
||||
'ssh-connections': '🔐',
|
||||
notes: '📝',
|
||||
'subscribed-notes': '🔔',
|
||||
};
|
||||
|
||||
/** Virtual subdirs that don't show a file list (they render custom panel content instead). */
|
||||
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
|
||||
|
||||
export function FileTree({
|
||||
subdirData,
|
||||
selectedSubdir,
|
||||
selectedFile,
|
||||
onSelectSubdir,
|
||||
onSelectFile,
|
||||
onDeleteFile,
|
||||
}: FileTreeProps) {
|
||||
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
{subdirData.map(({ subdir, files, loading }) => {
|
||||
const isOpen = selectedSubdir === subdir;
|
||||
const isVirtual = VIRTUAL_SUBDIRS.has(subdir);
|
||||
return (
|
||||
<div key={subdir}>
|
||||
{/* Subdir header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectSubdir(subdir)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors hover:bg-surface-2 ${
|
||||
isOpen ? 'bg-surface-2 text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xs">{isOpen ? '▾' : '▸'}</span>
|
||||
<span>{SUBDIR_ICONS[subdir]}</span>
|
||||
<span className="flex-1 text-left">{SUBDIR_LABELS[subdir]}{subdir !== 'agents-md' ? '/' : ''}</span>
|
||||
{!isVirtual && (
|
||||
<span className="text-[10px] font-mono text-slate-400 tabular-nums">
|
||||
{loading ? '…' : files.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* File list — only for non-virtual subdirs */}
|
||||
{isOpen && !isVirtual && (
|
||||
<div className="ml-4 border-l border-hairline pl-2 pb-1">
|
||||
{loading && (
|
||||
<div className="text-2xs text-slate-400 px-2 py-1.5">Loading…</div>
|
||||
)}
|
||||
{!loading && files.length === 0 && (
|
||||
<div className="text-2xs text-slate-400 px-2 py-1.5">Empty</div>
|
||||
)}
|
||||
{!loading && files.map(file => {
|
||||
const fileKey = `${subdir}/${file.name}`;
|
||||
const isSelected = selectedSubdir === subdir && selectedFile === file.name;
|
||||
return (
|
||||
<div
|
||||
key={file.name}
|
||||
className={`group flex items-center gap-1 px-2 py-1 rounded text-2xs cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-slate-700 hover:bg-surface-2'
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredFile(fileKey)}
|
||||
onMouseLeave={() => setHoveredFile(null)}
|
||||
onClick={() => onSelectFile(subdir, file.name)}
|
||||
>
|
||||
<span className="flex-1 truncate font-mono">{file.name}</span>
|
||||
{(hoveredFile === fileKey || isSelected) && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete ${file.name}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDeleteFile(subdir, file.name);
|
||||
}}
|
||||
className={`flex-shrink-0 w-4 h-4 flex items-center justify-center rounded hover:bg-red-100 hover:text-red-600 transition-colors ${
|
||||
isSelected ? 'text-accent-fg/70' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface ConnectionRow {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
connected: boolean;
|
||||
authKind: 'oauth' | 'api_key';
|
||||
ownerId: string | null;
|
||||
}
|
||||
|
||||
interface ConnectionListResponse {
|
||||
connections: ConnectionRow[];
|
||||
}
|
||||
|
||||
async function fetchConnections(): Promise<ConnectionRow[]> {
|
||||
const res = await fetch('/api/mcp/connections', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data: ConnectionListResponse = await res.json();
|
||||
return data.connections ?? [];
|
||||
}
|
||||
|
||||
async function disconnectMcp(serverId: string): Promise<void> {
|
||||
const res = await fetch(`/api/mcp/connections/${encodeURIComponent(serverId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
function OwnerBadge({ ownerId }: { ownerId: string | null }) {
|
||||
if (ownerId === null) {
|
||||
return (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">
|
||||
global
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 text-blue-600 leading-none">
|
||||
personal
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function McpConnectionsPanel() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['mcp-connections'],
|
||||
queryFn: fetchConnections,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const disconnect = useMutation({
|
||||
mutationFn: disconnectMcp,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['mcp-connections'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP 接続</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
外部 MCP サーバーとの連携を管理します。OAuth サーバーは「連携する」を押すと
|
||||
外部サービスの認可ページに飛び、戻ってくると自動で連携が確立します。
|
||||
API key サーバーはサーバー登録と同時に接続済みになります。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading && <div className="text-[13px] text-slate-400">Loading…</div>}
|
||||
{error && <div className="text-[13px] text-red-500">読み込みに失敗しました: {String(error)}</div>}
|
||||
{!isLoading && !error && (data?.length ?? 0) === 0 && (
|
||||
<div className="text-[13px] text-slate-400">
|
||||
利用可能な MCP サーバーがありません。「mcp-servers/」でサーバーを登録してください。
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-hairline">
|
||||
{(data ?? []).map((c) => (
|
||||
<li key={c.serverId} className="py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[13px] font-medium text-slate-900 truncate">{c.serverName}</span>
|
||||
<OwnerBadge ownerId={c.ownerId} />
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 font-mono truncate">{c.serverId}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{c.authKind === 'oauth' ? (
|
||||
c.connected ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-emerald-600 font-medium">連携済み</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-500 hover:text-slate-700 underline"
|
||||
onClick={() => {
|
||||
if (window.confirm(`${c.serverName} の連携を解除しますか?`)) {
|
||||
disconnect.mutate(c.serverId);
|
||||
}
|
||||
}}
|
||||
disabled={disconnect.isPending}
|
||||
>解除</button>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
className="px-3 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
|
||||
href={`/auth/mcp/${encodeURIComponent(c.serverId)}/start`}
|
||||
>
|
||||
連携する
|
||||
</a>
|
||||
)
|
||||
) : (
|
||||
/* api_key */
|
||||
c.connected ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-emerald-600 font-medium">API key 接続済み</span>
|
||||
{c.ownerId !== null && (
|
||||
<span className="text-2xs text-slate-400">削除は mcp-servers タブから</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-amber-600">API key が未設定です</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
interface ServerPublic {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
authKind: 'oauth' | 'api_key';
|
||||
ownerId: string | null;
|
||||
oauthClientId: string | null;
|
||||
oauthScopes: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
toolCount?: number;
|
||||
}
|
||||
|
||||
interface ConnectionRow {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
connected: boolean;
|
||||
authKind: 'oauth' | 'api_key';
|
||||
ownerId: string | null;
|
||||
}
|
||||
|
||||
interface ServerFormBody {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
authKind: 'oauth' | 'api_key';
|
||||
oauthClientId?: string;
|
||||
oauthClientSecret?: string;
|
||||
oauthScopes?: string;
|
||||
staticToken?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAdminServers(): Promise<ServerPublic[]> {
|
||||
const res = await fetch('/api/mcp/servers', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return ((await res.json()) as { servers: ServerPublic[] }).servers ?? [];
|
||||
}
|
||||
|
||||
async function fetchUserServers(): Promise<ServerPublic[]> {
|
||||
const res = await fetch('/api/mcp/user-servers', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return ((await res.json()) as { servers: ServerPublic[] }).servers ?? [];
|
||||
}
|
||||
|
||||
async function fetchConnections(): Promise<ConnectionRow[]> {
|
||||
const res = await fetch('/api/mcp/connections', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return ((await res.json()) as { connections: ConnectionRow[] }).connections ?? [];
|
||||
}
|
||||
|
||||
async function upsertServer(body: ServerFormBody, isGlobal: boolean): Promise<void> {
|
||||
const url = isGlobal ? '/api/mcp/servers' : '/api/mcp/user-servers';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(id: string, isGlobal: boolean): Promise<void> {
|
||||
const url = isGlobal
|
||||
? `/api/mcp/servers/${encodeURIComponent(id)}`
|
||||
: `/api/mcp/user-servers/${encodeURIComponent(id)}`;
|
||||
const res = await fetch(url, { method: 'DELETE', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
async function refreshTools(id: string, isGlobal: boolean): Promise<void> {
|
||||
const url = isGlobal
|
||||
? `/api/mcp/servers/${encodeURIComponent(id)}/tools/refresh`
|
||||
: `/api/mcp/user-servers/${encodeURIComponent(id)}/tools/refresh`;
|
||||
const res = await fetch(url, { method: 'POST', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
async function disconnectMcp(serverId: string): Promise<void> {
|
||||
const res = await fetch(`/api/mcp/connections/${encodeURIComponent(serverId)}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────
|
||||
|
||||
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-2xs text-slate-600 mb-1">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const INPUT_CLS = 'w-full border border-hairline rounded px-2 py-1 text-[13px]';
|
||||
|
||||
const emptyForm = (): ServerFormBody => ({
|
||||
id: '', name: '', url: '', authKind: 'oauth',
|
||||
oauthClientId: '', oauthClientSecret: '', oauthScopes: '', staticToken: '',
|
||||
});
|
||||
|
||||
function ServerForm({
|
||||
initial,
|
||||
isEdit,
|
||||
sectionLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isPending,
|
||||
}: {
|
||||
initial: ServerFormBody;
|
||||
isEdit: boolean;
|
||||
sectionLabel: string;
|
||||
onSubmit: (body: ServerFormBody) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState<ServerFormBody>(initial);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
if (!isEdit) setForm(emptyForm());
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-3 max-w-xl" onSubmit={handleSubmit}>
|
||||
<FormField label="ID (slug, 例: canva)">
|
||||
<input className={INPUT_CLS} value={form.id}
|
||||
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
||||
placeholder="canva" pattern="[a-z0-9_-]{1,64}" required disabled={isEdit}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="表示名">
|
||||
<input className={INPUT_CLS} value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Canva" required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="MCP URL (https://...)">
|
||||
<input className={INPUT_CLS} value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="https://example.com/mcp" type="url" required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<fieldset>
|
||||
<legend className="block text-2xs text-slate-600 mb-1">認証方式</legend>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
|
||||
<input type="radio" name={`authKind-${sectionLabel}`} value="oauth"
|
||||
checked={form.authKind === 'oauth'}
|
||||
onChange={() => setForm({ ...form, authKind: 'oauth' })}
|
||||
disabled={isEdit}
|
||||
/> OAuth
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
|
||||
<input type="radio" name={`authKind-${sectionLabel}`} value="api_key"
|
||||
checked={form.authKind === 'api_key'}
|
||||
onChange={() => setForm({ ...form, authKind: 'api_key' })}
|
||||
disabled={isEdit}
|
||||
/> API key
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{form.authKind === 'oauth' && (
|
||||
<>
|
||||
<FormField label="OAuth client_id">
|
||||
<input className={INPUT_CLS} value={form.oauthClientId ?? ''}
|
||||
onChange={(e) => setForm({ ...form, oauthClientId: e.target.value })}
|
||||
required={!isEdit}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={isEdit ? 'OAuth client_secret (空欄なら変更なし)' : 'OAuth client_secret'}>
|
||||
<input className={INPUT_CLS} type="password" value={form.oauthClientSecret ?? ''}
|
||||
onChange={(e) => setForm({ ...form, oauthClientSecret: e.target.value })}
|
||||
required={!isEdit}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="scopes (space-separated, 任意)">
|
||||
<input className={INPUT_CLS} value={form.oauthScopes ?? ''}
|
||||
onChange={(e) => setForm({ ...form, oauthScopes: e.target.value })}
|
||||
placeholder="read write"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.authKind === 'api_key' && (
|
||||
<FormField label={isEdit ? 'API key (空欄なら変更なし)' : 'API key / Bearer token'}>
|
||||
<input className={INPUT_CLS} type="password" value={form.staticToken ?? ''}
|
||||
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
|
||||
placeholder="sk-..." required={!isEdit}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{formError && <div className="text-xs text-red-600">{formError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={isPending}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50">
|
||||
{isPending ? '保存中…' : isEdit ? '更新' : '追加'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel}
|
||||
className="px-4 py-1.5 rounded-md text-xs text-slate-700 border border-hairline hover:bg-surface transition-colors">
|
||||
キャンセル
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeBadge({ ownerId }: { ownerId: string | null }) {
|
||||
return ownerId === null ? (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">global</span>
|
||||
) : (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 text-blue-600 leading-none">personal</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionBadge({ connection, serverId }: { connection?: ConnectionRow; serverId: string }) {
|
||||
if (!connection) return <span className="text-2xs text-slate-400">—</span>;
|
||||
if (connection.authKind === 'api_key') {
|
||||
return connection.connected
|
||||
? <span className="text-2xs text-emerald-600 font-medium">API key 接続済み</span>
|
||||
: <span className="text-2xs text-amber-600">API key 未設定</span>;
|
||||
}
|
||||
if (connection.connected) {
|
||||
return <span className="text-2xs text-emerald-600 font-medium">OAuth 連携済み</span>;
|
||||
}
|
||||
return (
|
||||
<a className="px-2 py-0.5 rounded text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
|
||||
href={`/auth/mcp/${encodeURIComponent(serverId)}/start`}>
|
||||
連携する
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
export function McpPanel({ showToast }: { showToast?: ShowToast }) {
|
||||
const qc = useQueryClient();
|
||||
const auth = useAuthState();
|
||||
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null);
|
||||
|
||||
const invalidateAll = () => {
|
||||
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
|
||||
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
|
||||
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
|
||||
};
|
||||
|
||||
const { data: globalServers, isLoading: globalLoading } = useQuery({
|
||||
queryKey: ['mcp-servers-admin'], queryFn: fetchAdminServers,
|
||||
staleTime: 30_000, enabled: isAdmin,
|
||||
});
|
||||
const { data: userServers, isLoading: userLoading } = useQuery({
|
||||
queryKey: ['mcp-user-servers'], queryFn: fetchUserServers, staleTime: 30_000,
|
||||
});
|
||||
const { data: connections } = useQuery({
|
||||
queryKey: ['mcp-connections'], queryFn: fetchConnections, staleTime: 30_000,
|
||||
});
|
||||
|
||||
const connMap = new Map((connections ?? []).map(c => [c.serverId, c]));
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: ({ body, isGlobal }: { body: ServerFormBody; isGlobal: boolean }) =>
|
||||
upsertServer(body, isGlobal),
|
||||
onSuccess: () => { invalidateAll(); setEditingId(null); setAddingSection(null); },
|
||||
onError: (err) => showToast?.('保存に失敗: ' + (err instanceof Error ? err.message : String(err)), 'error'),
|
||||
});
|
||||
|
||||
const delMut = useMutation({
|
||||
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => deleteServer(id, isGlobal),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const refreshMut = useMutation({
|
||||
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => refreshTools(id, isGlobal),
|
||||
onSuccess: invalidateAll,
|
||||
onError: (err) => showToast?.('ツール更新に失敗: ' + (err instanceof Error ? err.message : String(err)), 'error'),
|
||||
});
|
||||
|
||||
const disconnectMut = useMutation({
|
||||
mutationFn: disconnectMcp,
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const handleDelete = (id: string, name: string, isGlobal: boolean) => {
|
||||
const msg = isGlobal ? `${id} を削除しますか? 全ユーザーのトークンも失効します。` : `${name} を削除しますか?`;
|
||||
if (window.confirm(msg)) delMut.mutate({ id, isGlobal });
|
||||
};
|
||||
|
||||
const renderServerRow = (s: ServerPublic, isGlobal: boolean) => {
|
||||
const conn = connMap.get(s.id);
|
||||
const isEditing = editingId === s.id;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div key={s.id} className="p-3 bg-surface/50 border border-hairline rounded-md">
|
||||
<ServerForm
|
||||
initial={{ id: s.id, name: s.name, url: s.url, authKind: s.authKind,
|
||||
oauthClientId: s.oauthClientId ?? '', oauthClientSecret: '', oauthScopes: s.oauthScopes ?? '',
|
||||
staticToken: '', enabled: s.enabled }}
|
||||
isEdit sectionLabel={`edit-${s.id}`}
|
||||
onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal }); }}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isPending={saveMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={s.id} className="flex items-center gap-3 py-2.5 border-b border-hairline last:border-b-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[13px] font-medium text-slate-900">{s.name}</span>
|
||||
<ScopeBadge ownerId={s.ownerId} />
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${
|
||||
s.authKind === 'oauth' ? 'bg-purple-50 text-purple-600' : 'bg-amber-50 text-amber-600'
|
||||
}`}>{s.authKind === 'oauth' ? 'OAuth' : 'API key'}</span>
|
||||
{s.toolCount != null && s.toolCount > 0 && (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-50 text-green-700 leading-none">
|
||||
{s.toolCount} ツール
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 font-mono truncate mt-0.5">{s.url}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ConnectionBadge connection={conn} serverId={s.id} />
|
||||
|
||||
{conn?.connected && conn.authKind === 'oauth' && (
|
||||
<button type="button" className="text-2xs text-slate-500 hover:text-slate-700 underline"
|
||||
onClick={() => { if (window.confirm(`${s.name} の連携を解除しますか?`)) disconnectMut.mutate(s.id); }}
|
||||
disabled={disconnectMut.isPending}>
|
||||
解除
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button type="button" className="text-2xs text-slate-600 hover:text-slate-800 underline"
|
||||
onClick={() => refreshMut.mutate({ id: s.id, isGlobal })}
|
||||
disabled={refreshMut.isPending}>
|
||||
ツール更新
|
||||
</button>
|
||||
<button type="button" className="text-2xs text-slate-600 hover:text-slate-800 underline"
|
||||
onClick={() => setEditingId(s.id)}>
|
||||
編集
|
||||
</button>
|
||||
<button type="button" className="text-2xs text-red-600 hover:text-red-800 underline"
|
||||
onClick={() => handleDelete(s.id, s.name, isGlobal)}
|
||||
disabled={delMut.isPending}>
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading = globalLoading || userLoading;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP サーバー</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
MCP サーバーの登録・接続管理・設定変更をまとめて行えます。
|
||||
OAuth credentials と API key は AES-256-GCM で暗号化されて保存されます。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-[13px] text-slate-400">読み込み中…</div>}
|
||||
|
||||
{/* Global Servers (admin) */}
|
||||
{isAdmin && (globalServers ?? []).length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">Global サーバー</h3>
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">全ユーザー共有</span>
|
||||
</div>
|
||||
<div>{(globalServers ?? []).map(s => renderServerRow(s, true))}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Personal Servers */}
|
||||
{(userServers ?? []).length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">個人サーバー</h3>
|
||||
</div>
|
||||
<div>{(userServers ?? []).map(s => renderServerRow(s, false))}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && (globalServers ?? []).length === 0 && (userServers ?? []).length === 0 && (
|
||||
<div className="text-[13px] text-slate-400 text-center py-8">
|
||||
MCP サーバーがまだ登録されていません。下のボタンから追加してください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add buttons / forms */}
|
||||
<div className="space-y-4">
|
||||
{addingSection ? (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-700 mb-2">
|
||||
{addingSection === 'global' ? 'Global サーバーを追加' : 'Personal サーバーを追加'}
|
||||
</h4>
|
||||
<ServerForm
|
||||
initial={emptyForm()} isEdit={false}
|
||||
sectionLabel={addingSection}
|
||||
onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal: addingSection === 'global' }); }}
|
||||
onCancel={() => setAddingSection(null)}
|
||||
isPending={saveMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setAddingSection('personal')}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold text-accent border border-accent/30 hover:bg-accent-soft transition-colors">
|
||||
+ Personal サーバーを追加
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAddingSection('global')}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold text-slate-600 border border-hairline hover:bg-surface transition-colors">
|
||||
+ Global サーバーを追加
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ServerPublic {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
authKind: 'oauth' | 'api_key';
|
||||
ownerId: string | null;
|
||||
oauthClientId: string | null;
|
||||
oauthScopes: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authorizationEndpoint: string | null;
|
||||
toolCount?: number;
|
||||
}
|
||||
|
||||
interface ServerListResponse {
|
||||
servers: ServerPublic[];
|
||||
}
|
||||
|
||||
interface UserServerListResponse {
|
||||
servers: ServerPublic[];
|
||||
}
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAdminServers(): Promise<ServerPublic[]> {
|
||||
const res = await fetch('/api/mcp/servers', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data: ServerListResponse = await res.json();
|
||||
return data.servers ?? [];
|
||||
}
|
||||
|
||||
async function fetchUserServers(): Promise<ServerPublic[]> {
|
||||
const res = await fetch('/api/mcp/user-servers', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data: UserServerListResponse = await res.json();
|
||||
return data.servers ?? [];
|
||||
}
|
||||
|
||||
async function createGlobalServer(body: ServerFormBody): Promise<void> {
|
||||
const res = await fetch('/api/mcp/servers', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserServer(body: ServerFormBody): Promise<void> {
|
||||
const res = await fetch('/api/mcp/user-servers', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(id: string, isGlobal: boolean): Promise<void> {
|
||||
const url = isGlobal
|
||||
? `/api/mcp/servers/${encodeURIComponent(id)}`
|
||||
: `/api/mcp/user-servers/${encodeURIComponent(id)}`;
|
||||
const res = await fetch(url, { method: 'DELETE', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
async function refreshTools(id: string, isGlobal: boolean): Promise<void> {
|
||||
const url = isGlobal
|
||||
? `/api/mcp/servers/${encodeURIComponent(id)}/tools/refresh`
|
||||
: `/api/mcp/user-servers/${encodeURIComponent(id)}/tools/refresh`;
|
||||
const res = await fetch(url, { method: 'POST', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
// ── Form types & helpers ──────────────────────────────────────────────────────
|
||||
|
||||
interface ServerFormBody {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
authKind: 'oauth' | 'api_key';
|
||||
oauthClientId?: string;
|
||||
oauthClientSecret?: string;
|
||||
oauthScopes?: string;
|
||||
staticToken?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (): ServerFormBody => ({
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
authKind: 'oauth',
|
||||
oauthClientId: '',
|
||||
oauthClientSecret: '',
|
||||
oauthScopes: '',
|
||||
staticToken: '',
|
||||
});
|
||||
|
||||
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-2xs text-slate-600 mb-1">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AddServerForm ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface AddServerFormProps {
|
||||
sectionLabel: string;
|
||||
onSubmit: (body: ServerFormBody) => Promise<void>;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
function AddServerForm({ sectionLabel, onSubmit, isPending }: AddServerFormProps) {
|
||||
const [form, setForm] = useState<ServerFormBody>(emptyForm());
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
setForm(emptyForm());
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-3 max-w-xl" onSubmit={handleSubmit}>
|
||||
<FormField label="ID (slug, 例: canva)">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
value={form.id}
|
||||
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
||||
placeholder="canva"
|
||||
pattern="[a-z0-9_-]{1,64}"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="表示名">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Canva"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="MCP URL (https://...)">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="https://example.com/mcp"
|
||||
type="url"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Auth kind radio */}
|
||||
<fieldset>
|
||||
<legend className="block text-2xs text-slate-600 mb-1">認証方式</legend>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`authKind-${sectionLabel}`}
|
||||
value="oauth"
|
||||
checked={form.authKind === 'oauth'}
|
||||
onChange={() => setForm({ ...form, authKind: 'oauth' })}
|
||||
/>
|
||||
OAuth
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`authKind-${sectionLabel}`}
|
||||
value="api_key"
|
||||
checked={form.authKind === 'api_key'}
|
||||
onChange={() => setForm({ ...form, authKind: 'api_key' })}
|
||||
/>
|
||||
API key
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* OAuth-only fields */}
|
||||
{form.authKind === 'oauth' && (
|
||||
<>
|
||||
<FormField label="OAuth client_id">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
value={form.oauthClientId ?? ''}
|
||||
onChange={(e) => setForm({ ...form, oauthClientId: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="OAuth client_secret">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
type="password"
|
||||
value={form.oauthClientSecret ?? ''}
|
||||
onChange={(e) => setForm({ ...form, oauthClientSecret: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="scopes (space-separated, 任意)">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
value={form.oauthScopes ?? ''}
|
||||
onChange={(e) => setForm({ ...form, oauthScopes: e.target.value })}
|
||||
placeholder="read write"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* API key field */}
|
||||
{form.authKind === 'api_key' && (
|
||||
<FormField label="API key / Bearer token">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
type="password"
|
||||
value={form.staticToken ?? ''}
|
||||
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<div className="text-xs text-red-600">{formError}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? '保存中…' : '追加'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ServerTable ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface ServerTableProps {
|
||||
servers: ServerPublic[];
|
||||
isGlobal: boolean;
|
||||
canDelete: boolean;
|
||||
onRefresh: (id: string, isGlobal: boolean) => void;
|
||||
onDelete: (id: string, name: string, isGlobal: boolean) => void;
|
||||
refreshPending: boolean;
|
||||
deletePending: boolean;
|
||||
}
|
||||
|
||||
function ServerTable({
|
||||
servers,
|
||||
isGlobal,
|
||||
canDelete,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
refreshPending,
|
||||
deletePending,
|
||||
}: ServerTableProps) {
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="text-[13px] text-slate-400">
|
||||
{isGlobal ? '登録された global サーバーがありません。' : 'あなたのサーバーがありません。'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="text-left text-2xs uppercase tracking-wide text-slate-500">
|
||||
<tr className="border-b border-hairline">
|
||||
<th className="py-2 pr-2">ID</th>
|
||||
<th className="py-2 pr-2">名前</th>
|
||||
<th className="py-2 pr-2">URL</th>
|
||||
<th className="py-2 pr-2 w-20">認証</th>
|
||||
<th className="py-2 pr-2 w-16">有効</th>
|
||||
<th className="py-2 pr-2 w-24">ツール数</th>
|
||||
<th className="py-2 pr-2 w-40">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-hairline">
|
||||
{servers.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="py-2 pr-2 font-mono">{s.id}</td>
|
||||
<td className="py-2 pr-2">{s.name}</td>
|
||||
<td className="py-2 pr-2 font-mono text-2xs truncate max-w-xs" title={s.url}>{s.url}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${
|
||||
s.authKind === 'oauth'
|
||||
? 'bg-purple-50 text-purple-600'
|
||||
: 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
{s.authKind === 'oauth' ? 'OAuth' : 'API key'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-2">{s.enabled ? '✓' : '—'}</td>
|
||||
<td className="py-2 pr-2">
|
||||
{s.toolCount == null || s.toolCount === 0 ? (
|
||||
<span className="text-[10px] text-slate-400 italic">未取得 — ツール更新を押してください</span>
|
||||
) : (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-50 text-green-700 leading-none">
|
||||
{s.toolCount} ツール
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-2 space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xs text-slate-600 hover:text-slate-800 underline"
|
||||
onClick={() => onRefresh(s.id, isGlobal)}
|
||||
disabled={refreshPending}
|
||||
>ツール更新</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xs text-red-600 hover:text-red-800 underline"
|
||||
onClick={() => onDelete(s.id, s.name, isGlobal)}
|
||||
disabled={deletePending}
|
||||
>削除</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── McpServersPanel ───────────────────────────────────────────────────────────
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
interface McpServersPanelProps {
|
||||
showToast?: ShowToast;
|
||||
}
|
||||
|
||||
export function McpServersPanel({ showToast }: McpServersPanelProps = {}) {
|
||||
const qc = useQueryClient();
|
||||
const auth = useAuthState();
|
||||
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
|
||||
|
||||
// Admin fetches global servers from /api/mcp/servers
|
||||
const { data: globalServers, isLoading: globalLoading, error: globalError } = useQuery({
|
||||
queryKey: ['mcp-servers-admin'],
|
||||
queryFn: fetchAdminServers,
|
||||
staleTime: 30_000,
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
// All users fetch their own servers
|
||||
const { data: userServers, isLoading: userLoading, error: userError } = useQuery({
|
||||
queryKey: ['mcp-user-servers'],
|
||||
queryFn: fetchUserServers,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const createGlobal = useMutation({
|
||||
mutationFn: createGlobalServer,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
|
||||
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
|
||||
},
|
||||
});
|
||||
|
||||
const createUser = useMutation({
|
||||
mutationFn: createUserServer,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
|
||||
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
|
||||
},
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) =>
|
||||
deleteServer(id, isGlobal),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
|
||||
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
|
||||
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = useMutation({
|
||||
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) =>
|
||||
refreshTools(id, isGlobal),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
|
||||
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = 'ツール更新に失敗: ' + (err instanceof Error ? err.message : String(err));
|
||||
if (showToast) showToast(msg, 'error');
|
||||
else console.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (id: string, name: string, isGlobal: boolean) => {
|
||||
const msg = isGlobal
|
||||
? `${id} を削除しますか? 全ユーザーのトークンも失効します。`
|
||||
: `${name} を削除しますか?`;
|
||||
if (window.confirm(msg)) {
|
||||
del.mutate({ id, isGlobal });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = (id: string, isGlobal: boolean) => {
|
||||
refresh.mutate({ id, isGlobal });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP サーバー管理</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
MCP サーバーを登録すると、エージェントがそのサーバーのツールを利用できるようになります。
|
||||
{isAdmin
|
||||
? ' 管理者は全ユーザー共有の global サーバーと自分専用の personal サーバーを登録できます。'
|
||||
: ' あなた専用の personal サーバーを登録できます。'}
|
||||
OAuth credentials と API key は AES-256-GCM で暗号化されて保存されます。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Global Servers (admin only) ──────────────────────────────── */}
|
||||
{isAdmin && (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">Global Servers (admin 管理)</h3>
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">
|
||||
全ユーザー共有
|
||||
</span>
|
||||
</div>
|
||||
{globalLoading && <div className="text-[13px] text-slate-400">読み込み中…</div>}
|
||||
{globalError && (
|
||||
<div className="text-[13px] text-red-500">読み込みに失敗しました: {String(globalError)}</div>
|
||||
)}
|
||||
{!globalLoading && !globalError && (
|
||||
<ServerTable
|
||||
servers={globalServers ?? []}
|
||||
isGlobal={true}
|
||||
canDelete={true}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
refreshPending={refresh.isPending}
|
||||
deletePending={del.isPending}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-700 mb-2">Global サーバーを追加</h4>
|
||||
<AddServerForm
|
||||
sectionLabel="global"
|
||||
onSubmit={createGlobal.mutateAsync}
|
||||
isPending={createGlobal.isPending}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Your Servers (all users) ──────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">個人サーバー</h3>
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 text-blue-600 leading-none">
|
||||
personal
|
||||
</span>
|
||||
</div>
|
||||
{userLoading && <div className="text-[13px] text-slate-400">読み込み中…</div>}
|
||||
{userError && (
|
||||
<div className="text-[13px] text-red-500">読み込みに失敗しました: {String(userError)}</div>
|
||||
)}
|
||||
{!userLoading && !userError && (
|
||||
<ServerTable
|
||||
servers={userServers ?? []}
|
||||
isGlobal={false}
|
||||
canDelete={true}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
refreshPending={refresh.isPending}
|
||||
deletePending={del.isPending}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-700 mb-2">Personal サーバーを追加</h4>
|
||||
<AddServerForm
|
||||
sectionLabel="personal"
|
||||
onSubmit={createUser.mutateAsync}
|
||||
isPending={createUser.isPending}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import type { SubdirId } from './FileTree';
|
||||
|
||||
interface MonacoFileEditorProps {
|
||||
subdir: SubdirId;
|
||||
filename: string;
|
||||
content: string;
|
||||
mtime: string;
|
||||
size: number;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
function detectLanguage(filename: string): string {
|
||||
const dotIdx = filename.lastIndexOf('.');
|
||||
const ext = dotIdx >= 0 ? filename.slice(dotIdx).toLowerCase() : '';
|
||||
switch (ext) {
|
||||
case '.js': return 'javascript';
|
||||
case '.ts': return 'typescript';
|
||||
case '.md': return 'markdown';
|
||||
case '.json': return 'json';
|
||||
case '.yaml':
|
||||
case '.yml': return 'yaml';
|
||||
case '.sh': return 'shell';
|
||||
case '.py': return 'python';
|
||||
default: return 'plaintext';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatMtime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('ja-JP', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function MonacoFileEditor({ subdir, filename, content, mtime, size, onSave, onDirtyChange }: MonacoFileEditorProps) {
|
||||
const [localContent, setLocalContent] = useState(content);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
|
||||
|
||||
// Fix #2 (part): Reset editor state on file navigation only — not on background refetch.
|
||||
// content is intentionally excluded from deps so a refetchOnWindowFocus doesn't clobber edits.
|
||||
useEffect(() => {
|
||||
setLocalContent(content);
|
||||
setDirty(false);
|
||||
setSaveError(null);
|
||||
}, [subdir, filename]); // eslint-disable-line react-hooks/exhaustive-deps -- intentional: do not depend on content
|
||||
|
||||
// Fix #5: Notify parent when dirty state changes so the nav guard can run there.
|
||||
useEffect(() => {
|
||||
onDirtyChange(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const isReadOnly = subdir === 'trash' || subdir === 'memory';
|
||||
const language = detectLanguage(filename);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isReadOnly || !dirty || saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await onSave(localContent);
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fix #1: Keep a ref that always points to the latest handleSave so the
|
||||
// keyboard shortcut (bound once at mount) never closes over a stale version.
|
||||
const handleSaveRef = useRef<() => void | Promise<void>>(() => {});
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const handleMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
// Cmd/Ctrl+S binding — dispatches through the ref so it always sees the
|
||||
// latest dirty state, not the value captured at mount time.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
void handleSaveRef.current();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* File header */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-hairline bg-white">
|
||||
<span className="text-xs font-mono font-semibold text-slate-800 truncate">
|
||||
{filename}
|
||||
</span>
|
||||
{dirty && (
|
||||
<span className="text-[10px] font-medium text-amber-600 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5">
|
||||
unsaved
|
||||
</span>
|
||||
)}
|
||||
{isReadOnly && (
|
||||
<span className="text-[10px] font-medium text-slate-500 bg-surface-2 border border-hairline rounded px-1.5 py-0.5">
|
||||
read-only
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-slate-400 font-mono hidden sm:inline">
|
||||
{language}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Monaco editor */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language}
|
||||
value={localContent}
|
||||
options={{
|
||||
readOnly: isReadOnly,
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
}}
|
||||
onMount={handleMount}
|
||||
onChange={val => {
|
||||
if (!isReadOnly) {
|
||||
setLocalContent(val ?? '');
|
||||
setDirty((val ?? '') !== content);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer: save button + metadata */}
|
||||
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-2.5 border-t border-hairline bg-white">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{saveError && (
|
||||
<span className="text-2xs text-red-600">{saveError}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-slate-400 font-mono tabular-nums">
|
||||
{formatSize(size)} · {formatMtime(mtime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type WritableSubdir = 'scripts' | 'browser-macros' | 'templates';
|
||||
|
||||
interface NewFileFormProps {
|
||||
subdir: WritableSubdir;
|
||||
existingFilenames: string[];
|
||||
onCreate: (filename: string, skeleton: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const SKELETON: Record<WritableSubdir, { ext: string; body: string }> = {
|
||||
scripts: {
|
||||
ext: '.js',
|
||||
body: `---
|
||||
description: <short summary>
|
||||
params:
|
||||
# name: { type: string, required: true, description: "..." }
|
||||
---
|
||||
|
||||
/**
|
||||
* Generated ${TODAY} via User Folder UI.
|
||||
*/
|
||||
export async function main({ params }) {
|
||||
console.log('script start', params);
|
||||
return { ok: true };
|
||||
}
|
||||
`,
|
||||
},
|
||||
'browser-macros': {
|
||||
ext: '.js',
|
||||
body: `---
|
||||
description: <short summary>
|
||||
# session_profile_id: 1 # optional: bind to a saved login profile
|
||||
params:
|
||||
# url: { type: string, required: true }
|
||||
---
|
||||
|
||||
/**
|
||||
* Generated ${TODAY} via User Folder UI.
|
||||
*/
|
||||
export async function main({ context, params }) {
|
||||
const page = await context.newPage();
|
||||
// await page.goto(params.url);
|
||||
return { ok: true };
|
||||
}
|
||||
`,
|
||||
},
|
||||
templates: {
|
||||
ext: '.md',
|
||||
body: `---
|
||||
description: <short summary>
|
||||
params:
|
||||
# title: { type: string, required: true }
|
||||
---
|
||||
|
||||
# {{title}}
|
||||
|
||||
Content here.
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
const SUBDIR_LABEL: Record<WritableSubdir, string> = {
|
||||
scripts: 'スクリプト',
|
||||
'browser-macros': 'ブラウザマクロ',
|
||||
templates: 'テンプレート',
|
||||
};
|
||||
|
||||
export function NewFileForm({ subdir, existingFilenames, onCreate }: NewFileFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const config = SKELETON[subdir];
|
||||
const label = SUBDIR_LABEL[subdir];
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const baseName = name.trim().replace(new RegExp(`\\${config.ext}$`, 'i'), '');
|
||||
if (!baseName) {
|
||||
setError('ファイル名を入力してください');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(baseName)) {
|
||||
setError('英数字 / ハイフン / アンダースコアのみ使用可能 (スペース不可、拡張子は自動付与)');
|
||||
return;
|
||||
}
|
||||
const filename = `${baseName}${config.ext}`;
|
||||
if (existingFilenames.includes(filename)) {
|
||||
setError(`${filename} は既に存在します。別の名前を指定してください`);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onCreate(filename, config.body);
|
||||
setName('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border-t border-hairline pt-4 mt-4">
|
||||
<h3 className="text-[13px] font-semibold text-slate-900 mb-2">
|
||||
新規{label}を作成
|
||||
</h3>
|
||||
<div className="flex items-stretch gap-2 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-0 border border-hairline rounded px-2 py-1 text-[13px] font-mono"
|
||||
placeholder={`ファイル名 (拡張子なし、${config.ext} は自動付与)`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={submitting}
|
||||
pattern="[a-zA-Z0-9_-]+"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 px-3 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
disabled={submitting || !name.trim()}
|
||||
>
|
||||
{submitting ? '作成中…' : '作成'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-2 text-xs text-red-600">{error}</div>
|
||||
)}
|
||||
<p className="mt-2 text-2xs text-slate-500">
|
||||
作成後、エディタが開きます。スケルトン (frontmatter + main 関数) があらかじめ入っているので、編集して保存してください。
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface NotesPanelProps {
|
||||
filePath: string | null; // e.g. "cve/foo.md"
|
||||
onSaved?: () => void;
|
||||
onSelectFile?: (filePath: string) => void; // called after new note created
|
||||
}
|
||||
|
||||
interface ParsedFm {
|
||||
title: string;
|
||||
visibility: 'private' | 'org' | 'public';
|
||||
scope_org_id: string;
|
||||
mode_hint: '' | 'search' | 'inject';
|
||||
tags: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseMdToFmAndBody(md: string): ParsedFm {
|
||||
// Lightweight FM parse (UI-side)
|
||||
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(md);
|
||||
if (!m) {
|
||||
return { title: '', visibility: 'private', scope_org_id: '', mode_hint: '', tags: '', body: md };
|
||||
}
|
||||
const fmText = m[1]!;
|
||||
const body = m[2] ?? '';
|
||||
const get = (k: string): string => {
|
||||
const re = new RegExp(`^${k}:\\s*(.+)$`, 'm');
|
||||
const found = re.exec(fmText);
|
||||
return found ? found[1]!.trim().replace(/^['"]|['"]$/g, '') : '';
|
||||
};
|
||||
const tagsArr = /^tags:\s*\[([^\]]*)\]$/m.exec(fmText);
|
||||
return {
|
||||
title: get('title'),
|
||||
visibility: (get('visibility') as ParsedFm['visibility']) || 'private',
|
||||
scope_org_id: get('scope_org_id'),
|
||||
mode_hint: (get('mode_hint') as ParsedFm['mode_hint']) || '',
|
||||
tags: tagsArr ? tagsArr[1]!.split(',').map((s) => s.trim()).filter(Boolean).join(', ') : '',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeFm(p: ParsedFm): string {
|
||||
const fm: string[] = ['---'];
|
||||
if (p.title) fm.push(`title: ${p.title}`);
|
||||
fm.push(`visibility: ${p.visibility}`);
|
||||
if (p.visibility === 'org' && p.scope_org_id) fm.push(`scope_org_id: ${p.scope_org_id}`);
|
||||
if (p.mode_hint) fm.push(`mode_hint: ${p.mode_hint}`);
|
||||
const tagsArr = p.tags.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (tagsArr.length > 0) fm.push(`tags: [${tagsArr.join(', ')}]`);
|
||||
fm.push('---');
|
||||
fm.push('');
|
||||
fm.push(p.body);
|
||||
return fm.join('\n');
|
||||
}
|
||||
|
||||
const NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
function NewNoteForm({ onCreated }: { onCreated: (filePath: string) => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [folder, setFolder] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const fn = fileName.endsWith('.md') ? fileName : `${fileName}.md`;
|
||||
if (!NAME_RE.test(folder)) { setError('フォルダー名は英数字・. - _ のみ'); return; }
|
||||
if (!NAME_RE.test(fn)) { setError('ファイル名は英数字・. - _ のみ (.md で終わる)'); return; }
|
||||
setError('');
|
||||
setCreating(true);
|
||||
try {
|
||||
const stub = `---\nvisibility: private\n---\n\n`;
|
||||
const r = await fetch(
|
||||
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(`${folder}/${fn}`)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body: stub,
|
||||
},
|
||||
);
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({ error: 'save failed' }));
|
||||
throw new Error((j as { error?: string }).error ?? 'save failed');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
|
||||
onCreated(`${folder}/${fn}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 border border-hairline rounded-md p-4 bg-surface-2/40">
|
||||
<p className="text-[13px] font-semibold text-slate-700 mb-3">新しい Note を作成</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
className="flex-1 border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
placeholder="フォルダー名 (例: cve)"
|
||||
value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)}
|
||||
/>
|
||||
<span className="text-slate-400 text-[13px]">/</span>
|
||||
<input
|
||||
className="flex-1 border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
placeholder="ファイル名 (例: foo.md)"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-2xs text-red-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
disabled={creating || !folder || !fileName}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{creating ? '作成中…' : '+ New Note'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotesPanel({ filePath, onSaved, onSelectFile }: NotesPanelProps) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: fileText, isLoading, isError } = useQuery({
|
||||
queryKey: ['notes-file', filePath],
|
||||
queryFn: async () => {
|
||||
if (!filePath) return '';
|
||||
const r = await fetch(
|
||||
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(filePath)}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
return r.text();
|
||||
},
|
||||
enabled: !!filePath,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const [state, setState] = useState<ParsedFm>({
|
||||
title: '',
|
||||
visibility: 'private',
|
||||
scope_org_id: '',
|
||||
mode_hint: '',
|
||||
tags: '',
|
||||
body: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fileText !== undefined) setState(parseMdToFmAndBody(fileText));
|
||||
}, [fileText]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!filePath) return;
|
||||
const md = serializeFm(state);
|
||||
const r = await fetch(
|
||||
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body: md,
|
||||
},
|
||||
);
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({ error: 'save failed' }));
|
||||
throw new Error((j as { error?: string }).error ?? 'save failed');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notes-file', filePath] });
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
|
||||
onSaved?.();
|
||||
},
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="mb-6 flex gap-3">
|
||||
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>📝</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-semibold text-slate-900">Notes (共有)</h2>
|
||||
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">
|
||||
他のエージェントや他のユーザーと共有したい情報を Markdown で書く場所です。
|
||||
visibility を設定して公開範囲を制御できます。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-md p-4 text-[13px] font-mono text-slate-600 whitespace-pre leading-relaxed">
|
||||
{`# ファイル構成: notes/<folder>/<file>.md
|
||||
# 例: notes/cve/CVE-2026-1234.md
|
||||
|
||||
---
|
||||
title: CVE-2026-1234 メモ
|
||||
visibility: public # private | org | public
|
||||
mode_hint: inject # 推奨モード (任意)
|
||||
tags: [security, cve]
|
||||
---
|
||||
|
||||
## 概要
|
||||
...`}
|
||||
</div>
|
||||
<div className="mt-6 text-[13px] text-slate-500 space-y-2">
|
||||
<p>
|
||||
<span className="font-semibold text-slate-700">共有:</span>{' '}
|
||||
「Subscribed Notes」タブで他ユーザーの公開 notes を発見・購読できます。
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold text-slate-700">エージェント連携:</span>{' '}
|
||||
SearchNotes / ReadNote / WriteNote ツールでエージェントがアクセスできます。
|
||||
</p>
|
||||
</div>
|
||||
<NewNoteForm onCreated={(path) => {
|
||||
onSaved?.();
|
||||
onSelectFile?.(path);
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
|
||||
ファイルの読み込みに失敗しました。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[13px] font-semibold text-slate-900 font-mono">{filePath}</h2>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
frontmatter で公開範囲・モードを設定できます。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{save.isError && (
|
||||
<p className="mt-1 text-2xs text-red-600">{(save.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FM form */}
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/20">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{/* Title */}
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Title</span>
|
||||
<input
|
||||
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={state.title}
|
||||
onChange={(e) => setState({ ...state, title: e.target.value })}
|
||||
placeholder="(optional)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Visibility */}
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Visibility</span>
|
||||
<select
|
||||
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={state.visibility}
|
||||
onChange={(e) => setState({ ...state, visibility: e.target.value as ParsedFm['visibility'] })}
|
||||
>
|
||||
<option value="private">private</option>
|
||||
<option value="org">org</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Scope org id — conditional */}
|
||||
{state.visibility === 'org' && (
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Scope Org ID</span>
|
||||
<input
|
||||
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={state.scope_org_id}
|
||||
onChange={(e) => setState({ ...state, scope_org_id: e.target.value })}
|
||||
placeholder="gitea-org-name"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Mode hint */}
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Mode Hint</span>
|
||||
<select
|
||||
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={state.mode_hint}
|
||||
onChange={(e) => setState({ ...state, mode_hint: e.target.value as ParsedFm['mode_hint'] })}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
<option value="search">search</option>
|
||||
<option value="inject">inject</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Tags */}
|
||||
<label className="flex flex-col gap-0.5 col-span-2">
|
||||
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Tags (カンマ区切り)</span>
|
||||
<input
|
||||
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={state.tags}
|
||||
onChange={(e) => setState({ ...state, tags: e.target.value })}
|
||||
placeholder="security, cve, ..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown body */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<textarea
|
||||
className="w-full h-full resize-none p-4 font-mono text-[13px] text-slate-800 bg-white focus:outline-none"
|
||||
value={state.body}
|
||||
onChange={(e) => setState({ ...state, body: e.target.value })}
|
||||
placeholder="Markdown body…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
deletePet,
|
||||
fetchPets,
|
||||
fetchWorkerBackends,
|
||||
fetchWorkers,
|
||||
importPet,
|
||||
petAssetUrl,
|
||||
updatePetSettings,
|
||||
type PetSettings,
|
||||
type PetSummary,
|
||||
type WorkerBackendsResponse,
|
||||
type WorkerInfo,
|
||||
} from '../../api';
|
||||
import { PetSprite } from '../pets/PetSprite';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="text-[13px] font-medium text-slate-700">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-6 w-11 rounded-full text-left transition-colors ${
|
||||
checked ? 'bg-accent' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
useEffect(() => {
|
||||
const q = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReduced(q.matches);
|
||||
update();
|
||||
q.addEventListener('change', update);
|
||||
return () => q.removeEventListener('change', update);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
function PetPreview({ pet, size = 56 }: { pet: PetSummary; size?: number }) {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const spriteUrl = pet.spriteFile ? petAssetUrl(pet.id, pet.spriteFile) : null;
|
||||
const framesPerRow = usePetFrameAnalysis(spriteUrl, pet.gridCols, pet.gridRows);
|
||||
const canAnimate = !!(spriteUrl && pet.gridCols && pet.gridRows);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-hairline bg-surface overflow-hidden grid place-items-center shrink-0" style={{ width: size, height: size }}>
|
||||
{canAnimate ? (
|
||||
<PetSprite
|
||||
name={pet.name}
|
||||
imageUrl={spriteUrl}
|
||||
frameWidth={pet.frameWidth}
|
||||
frameHeight={pet.frameHeight}
|
||||
gridCols={pet.gridCols}
|
||||
gridRows={pet.gridRows}
|
||||
framesPerRow={framesPerRow}
|
||||
state="idle"
|
||||
size={size}
|
||||
reducedMotion={reduced}
|
||||
/>
|
||||
) : pet.previewFile || pet.spriteFile ? (
|
||||
<img
|
||||
src={petAssetUrl(pet.id, (pet.previewFile ?? pet.spriteFile)!)}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-400 text-xs font-mono">pet</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerMappingRow({
|
||||
workerLabel,
|
||||
workerSubLabel,
|
||||
selectedPetId,
|
||||
pets,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
workerLabel: string;
|
||||
workerSubLabel?: string | null;
|
||||
selectedPetId: string;
|
||||
pets: PetSummary[];
|
||||
disabled: boolean;
|
||||
onChange: (petId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-medium text-slate-700 truncate">{workerLabel}</div>
|
||||
{workerSubLabel && (
|
||||
<div className="text-2xs text-slate-500 font-mono truncate">{workerSubLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
className="input w-40"
|
||||
value={selectedPetId}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">(なし)</option>
|
||||
{pets.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PetsPanel({ showToast }: { showToast?: ShowToast }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: ['user-pets'],
|
||||
queryFn: fetchPets,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const workersQuery = useQuery<WorkerInfo[]>({
|
||||
queryKey: ['workers'],
|
||||
queryFn: fetchWorkers,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['user-pets'] });
|
||||
};
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => importPet(file),
|
||||
onSuccess: (pet) => {
|
||||
setImportError(null);
|
||||
showToast?.(`${pet.name} を import しました`, 'success');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : 'Import failed';
|
||||
setImportError(message);
|
||||
showToast?.(`Pet import に失敗しました: ${message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const settingsMutation = useMutation({
|
||||
mutationFn: (patch: Partial<PetSettings>) => updatePetSettings(patch),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : 'Settings update failed';
|
||||
showToast?.(`Pet 設定の保存に失敗しました: ${message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deletePet,
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : 'Delete failed';
|
||||
showToast?.(`Pet 削除に失敗しました: ${message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const settings = query.data?.settings;
|
||||
const pets = query.data?.pets ?? [];
|
||||
const workers = workersQuery.data ?? [];
|
||||
// Show the workers section whenever the user has at least one worker
|
||||
// AND we have a settings row to read from. Two prior gates were
|
||||
// dropped, each having broken a real verification flow:
|
||||
//
|
||||
// 1. `pets.length > 0` (removed earlier) hid the topology until
|
||||
// a Pet was imported, even though the topology view is what
|
||||
// operators want immediately after wiring a proxy worker.
|
||||
// 2. `workers.length > 1` hid the section in single-proxy-worker
|
||||
// setups — exactly the canonical "AAO client → AAO Gateway
|
||||
// with N backends" deployment. The user has 1 worker config
|
||||
// (the gateway) but the tree below expands to N backends that
|
||||
// they want to assign Pets to. Requiring >1 worker turned the
|
||||
// Pets page into a blank slate for this whole class of users.
|
||||
//
|
||||
// With workers.length >= 1, single-direct-worker setups show a
|
||||
// mildly redundant "default + 1 worker" pair, which is harmless.
|
||||
const showWorkerMapping = workers.length >= 1 && !!settings;
|
||||
const noPetsYet = pets.length === 0;
|
||||
const workerPets = settings?.workerPets ?? {};
|
||||
|
||||
// For each proxy worker, fetch its backend list so the panel can render
|
||||
// a tree (worker → backends) and allow per-backend pet assignment. Direct
|
||||
// workers don't fire the query (enabled=false) so we don't waste a
|
||||
// round-trip on a known-empty result.
|
||||
const proxyWorkers = workers.filter(w => w.proxy === true);
|
||||
const backendsQueries = useQueries({
|
||||
queries: proxyWorkers.map(w => ({
|
||||
queryKey: ['worker-backends', w.id] as const,
|
||||
queryFn: () => fetchWorkerBackends(w.id),
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
const backendsByWorker = new Map<string, WorkerBackendsResponse | undefined>();
|
||||
proxyWorkers.forEach((w, i) => backendsByWorker.set(w.id, backendsQueries[i]?.data));
|
||||
|
||||
// Per-proxy-worker collapsed/expanded state. Default: expanded so the
|
||||
// user sees their backends immediately on first load; toggling is a
|
||||
// local convenience for installs with many backends per pool.
|
||||
const [collapsedProxies, setCollapsedProxies] = useState<Record<string, boolean>>({});
|
||||
const toggleCollapse = (workerId: string) => {
|
||||
setCollapsedProxies(prev => ({ ...prev, [workerId]: !prev[workerId] }));
|
||||
};
|
||||
|
||||
const updateSettings = (patch: Partial<PetSettings>) => {
|
||||
settingsMutation.mutate(patch);
|
||||
};
|
||||
|
||||
const setDefaultPet = (petId: string) => {
|
||||
updateSettings({ activePetId: petId === '' ? null : petId });
|
||||
};
|
||||
|
||||
const setWorkerPet = (workerId: string, petId: string) => {
|
||||
const next = { ...workerPets };
|
||||
if (petId === '') {
|
||||
delete next[workerId];
|
||||
} else {
|
||||
next[workerId] = petId;
|
||||
}
|
||||
updateSettings({ workerPets: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">Codex Pets</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
Codex Pets 互換の zip を import し、Chat 画面右下に表示します。設定と pet はユーザーごとに保存されます。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) importMutation.mutate(file);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={importMutation.isPending}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{importMutation.isPending ? 'Importing…' : 'Pet zip を import'}
|
||||
</button>
|
||||
{importError && <span className="text-xs text-red-600 truncate">{importError}</span>}
|
||||
</div>
|
||||
|
||||
{query.isLoading && <div className="text-[13px] text-slate-400">Loading…</div>}
|
||||
{query.error && <div className="text-[13px] text-red-500">読み込みに失敗しました: {String(query.error)}</div>}
|
||||
|
||||
{settings && (
|
||||
<div className="mb-6 border-y border-hairline py-2">
|
||||
<ToggleRow
|
||||
label="Chat 画面に表示"
|
||||
checked={settings.enabled}
|
||||
disabled={settingsMutation.isPending}
|
||||
onChange={(enabled) => updateSettings({ enabled })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Tool Spark を表示"
|
||||
checked={settings.toolSparkEnabled}
|
||||
disabled={settingsMutation.isPending}
|
||||
onChange={(toolSparkEnabled) => updateSettings({ toolSparkEnabled })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="動きを抑える"
|
||||
checked={settings.reducedMotion}
|
||||
disabled={settingsMutation.isPending}
|
||||
onChange={(reducedMotion) => updateSettings({ reducedMotion })}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="text-[13px] font-medium text-slate-700">サイズ</span>
|
||||
<select
|
||||
className="input w-24"
|
||||
value={settings.size}
|
||||
disabled={settingsMutation.isPending}
|
||||
onChange={(event) => updateSettings({ size: Number(event.target.value) as PetSettings['size'] })}
|
||||
>
|
||||
<option value={32}>32</option>
|
||||
<option value={48}>48</option>
|
||||
<option value={64}>64</option>
|
||||
<option value={80}>80</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWorkerMapping && (
|
||||
<section className="mb-6 border-y border-hairline py-2" aria-labelledby="workers-heading">
|
||||
<div className="flex items-baseline justify-between pt-1 pb-2">
|
||||
<h3 id="workers-heading" className="text-[13px] font-semibold text-slate-700">
|
||||
Workers ({workers.length})
|
||||
</h3>
|
||||
<span className="text-2xs text-slate-500">
|
||||
{noPetsYet ? 'Worker / GPU pool 構成を確認できます' : 'Worker ごとに表示する pet を割り当て'}
|
||||
</span>
|
||||
</div>
|
||||
{noPetsYet && (
|
||||
<div className="text-2xs text-slate-500 bg-slate-50 border border-slate-200 rounded px-2 py-1 mb-2">
|
||||
pet を import すると、Worker / backend ごとに割り当てられるようになります。
|
||||
</div>
|
||||
)}
|
||||
<WorkerMappingRow
|
||||
workerLabel="default (未割当)"
|
||||
workerSubLabel="activePetId と同義"
|
||||
selectedPetId={settings?.activePetId ?? ''}
|
||||
pets={pets}
|
||||
disabled={settingsMutation.isPending || noPetsYet}
|
||||
onChange={setDefaultPet}
|
||||
/>
|
||||
{workers.map(worker => {
|
||||
const isProxy = worker.proxy === true;
|
||||
const rolesLabel = worker.roles.length > 0 ? worker.roles.join(',') : null;
|
||||
const subLabel = isProxy
|
||||
? `proxy${worker.proxyType ? `: ${worker.proxyType}` : ''}${rolesLabel ? ` · ${rolesLabel}` : ''}`
|
||||
: rolesLabel;
|
||||
|
||||
if (!isProxy) {
|
||||
return (
|
||||
<WorkerMappingRow
|
||||
key={worker.id}
|
||||
workerLabel={worker.id}
|
||||
workerSubLabel={subLabel}
|
||||
selectedPetId={workerPets[worker.id] ?? ''}
|
||||
pets={pets}
|
||||
disabled={settingsMutation.isPending || noPetsYet}
|
||||
onChange={(petId) => setWorkerPet(worker.id, petId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Proxy worker — collapsible tree rendering. The worker row
|
||||
// itself maps to workerPets[worker.id] (fallback when no
|
||||
// backend matches); each backend row maps to workerPets[backendId].
|
||||
const backendsResp = backendsByWorker.get(worker.id);
|
||||
const backends = backendsResp?.backends ?? [];
|
||||
const collapsed = !!collapsedProxies[worker.id];
|
||||
const backendError = backendsResp?.error ?? null;
|
||||
return (
|
||||
<div key={worker.id} className="border-l-2 border-slate-100 ml-1 pl-2">
|
||||
<div className="flex items-center justify-between gap-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(worker.id)}
|
||||
className="flex-1 min-w-0 text-left"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<div className="text-[13px] font-medium text-slate-700 truncate">
|
||||
<span className="inline-block w-3 text-slate-400">{collapsed ? '▶' : '▼'}</span>
|
||||
{' '}
|
||||
{worker.id}
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
|
||||
proxy
|
||||
</span>
|
||||
</div>
|
||||
{subLabel && (
|
||||
<div className="text-2xs text-slate-500 font-mono truncate pl-5">{subLabel}</div>
|
||||
)}
|
||||
</button>
|
||||
<select
|
||||
className="input w-40"
|
||||
value={workerPets[worker.id] ?? ''}
|
||||
disabled={settingsMutation.isPending || noPetsYet}
|
||||
onChange={(event) => setWorkerPet(worker.id, event.target.value)}
|
||||
title="proxy 既定 (どの backend にも個別マッピングが無い場合)"
|
||||
>
|
||||
<option value="">(なし / 既定)</option>
|
||||
{pets.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="pl-5">
|
||||
{backendError && (
|
||||
<div className="text-2xs text-red-600 py-1">
|
||||
backend 一覧取得に失敗: {backendError}
|
||||
</div>
|
||||
)}
|
||||
{!backendError && backends.length === 0 && (
|
||||
<div className="text-2xs text-slate-400 py-1">
|
||||
backend が見つかりません (proxy /v1/models が空)
|
||||
</div>
|
||||
)}
|
||||
{backends.map(backend => (
|
||||
<WorkerMappingRow
|
||||
key={backend.id}
|
||||
workerLabel={backend.id}
|
||||
workerSubLabel={backend.model && backend.model !== backend.id ? backend.model : null}
|
||||
selectedPetId={workerPets[backend.id] ?? ''}
|
||||
pets={pets}
|
||||
disabled={settingsMutation.isPending || noPetsYet}
|
||||
onChange={(petId) => setWorkerPet(backend.id, petId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!query.isLoading && pets.length === 0 && (
|
||||
<div className="text-[13px] text-slate-400">
|
||||
まだ pet はありません。`pet.json` と画像を含む zip を import してください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-hairline">
|
||||
{pets.map((pet) => {
|
||||
const active = settings?.activePetId === pet.id;
|
||||
return (
|
||||
<li key={pet.id} className="py-3 flex items-center gap-3">
|
||||
<PetPreview pet={pet} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-slate-900 truncate">{pet.name}</span>
|
||||
{active && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 font-mono truncate">{pet.id}</div>
|
||||
{pet.description && <p className="text-xs text-slate-500 mt-1 line-clamp-2">{pet.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={active || settingsMutation.isPending}
|
||||
onClick={() => updateSettings({ activePetId: pet.id })}
|
||||
>
|
||||
デフォルトに
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`${pet.name} を削除しますか?`)) {
|
||||
deleteMutation.mutate(pet.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { listBrowserSessionProfiles } from '../../api';
|
||||
|
||||
export interface ParamHint {
|
||||
name: string;
|
||||
valueToReplace: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
}
|
||||
|
||||
interface SaveAsScriptDialogProps {
|
||||
/** The name of the recording (without .json extension). */
|
||||
recordingName: string;
|
||||
onClose: () => void;
|
||||
/** Called with the new script filename (e.g. "my-script.js") after a successful compile. */
|
||||
onSuccess: (scriptName: string) => void;
|
||||
}
|
||||
|
||||
const SCRIPT_NAME_RE = /^[A-Za-z0-9_\-.]+$/;
|
||||
|
||||
async function apiCompileScript(body: {
|
||||
recordingName: string;
|
||||
scriptName: string;
|
||||
description: string;
|
||||
sessionProfileId?: number;
|
||||
paramHints?: ParamHint[];
|
||||
overwrite?: boolean;
|
||||
}): Promise<{ ok: boolean; scriptName: string }> {
|
||||
const { overwrite, ...rest } = body;
|
||||
const qs = overwrite ? '?overwrite=true' : '';
|
||||
const res = await fetch(`/api/users/me/browser-macros/compile${qs}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rest),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
|
||||
(err as any).status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function emptyHint(): ParamHint {
|
||||
return { name: '', valueToReplace: '', type: 'string' };
|
||||
}
|
||||
|
||||
export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAsScriptDialogProps) {
|
||||
const qc = useQueryClient();
|
||||
const [scriptName, setScriptName] = useState(recordingName);
|
||||
const [description, setDescription] = useState('');
|
||||
const [sessionProfileId, setSessionProfileId] = useState('');
|
||||
const [paramHints, setParamHints] = useState<ParamHint[]>([]);
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [conflictError, setConflictError] = useState<string | null>(null);
|
||||
|
||||
const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({
|
||||
queryKey: ['browser-session-profiles'],
|
||||
queryFn: listBrowserSessionProfiles,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
|
||||
|
||||
const compileMutation = useMutation({
|
||||
mutationFn: apiCompileScript,
|
||||
onSuccess: (data) => {
|
||||
// Invalidate browser-macros list so the new file appears
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
|
||||
onSuccess(data.scriptName);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (err.status === 409) {
|
||||
setConflictError('Script already exists; check the overwrite box and retry.');
|
||||
}
|
||||
// Other errors surface via compileMutation.error
|
||||
},
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
if (!scriptName.trim()) {
|
||||
setValidationError('Script name is required.');
|
||||
return false;
|
||||
}
|
||||
const nameWithoutExt = scriptName.endsWith('.js') ? scriptName.slice(0, -3) : scriptName;
|
||||
if (!SCRIPT_NAME_RE.test(nameWithoutExt)) {
|
||||
setValidationError('Script name may only contain letters, numbers, dashes, underscores, and dots.');
|
||||
return false;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setValidationError('Description is required.');
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < paramHints.length; i++) {
|
||||
const h = paramHints[i]!;
|
||||
if (!h.name.trim() || !h.valueToReplace.trim()) {
|
||||
setValidationError(`Param hint #${i + 1} must have a name and value to replace.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setValidationError(null);
|
||||
setConflictError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
const profileId = sessionProfileId.trim() !== '' ? parseInt(sessionProfileId, 10) : undefined;
|
||||
compileMutation.mutate({
|
||||
recordingName,
|
||||
scriptName: scriptName.trim(),
|
||||
description: description.trim(),
|
||||
sessionProfileId: profileId !== undefined && !isNaN(profileId) ? profileId : undefined,
|
||||
paramHints: paramHints.length > 0 ? paramHints : undefined,
|
||||
overwrite,
|
||||
});
|
||||
}
|
||||
|
||||
function addHint() {
|
||||
setParamHints(prev => [...prev, emptyHint()]);
|
||||
}
|
||||
|
||||
function updateHint(idx: number, patch: Partial<ParamHint>) {
|
||||
setParamHints(prev => prev.map((h, i) => i === idx ? { ...h, ...patch } : h));
|
||||
}
|
||||
|
||||
function removeHint(idx: number) {
|
||||
setParamHints(prev => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
const isSubmitting = compileMutation.isPending;
|
||||
const submitError = compileMutation.isError && !conflictError
|
||||
? ((compileMutation.error as any)?.message ?? 'Compile failed')
|
||||
: null;
|
||||
|
||||
return (
|
||||
/* Backdrop */
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-hairline">
|
||||
<span className="text-sm font-semibold text-slate-800 flex-1">Save as Script</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-surface-2 text-slate-400 hover:text-slate-700 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 3l10 10M13 3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-5 py-4 overflow-y-auto flex-1">
|
||||
{/* Recording name (read-only) */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Recording
|
||||
</label>
|
||||
<div className="px-3 py-2 rounded-md bg-surface-2 text-xs font-mono text-slate-700 border border-hairline">
|
||||
{recordingName}.json
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Script name */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="sas-script-name" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Script name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="sas-script-name"
|
||||
type="text"
|
||||
value={scriptName}
|
||||
onChange={e => setScriptName(e.target.value)}
|
||||
placeholder="my-script"
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] font-mono focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-400">Alphanumeric, dashes, underscores, dots. A .js extension will be added automatically.</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="sas-description" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="sas-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="What does this script do?"
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session profile */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="sas-session-profile" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Session profile <span className="text-slate-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
{profilesLoading ? (
|
||||
<div className="px-3 py-2 text-xs text-slate-500">Loading profiles…</div>
|
||||
) : activeSessionProfiles.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-slate-500">
|
||||
No active session profiles. Create one in the Browser tab to enable authenticated automation.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="sas-session-profile"
|
||||
value={sessionProfileId}
|
||||
onChange={e => setSessionProfileId(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{activeSessionProfiles.map(p => (
|
||||
<option key={p.id} value={String(p.id)}>{p.label} (#{p.id})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Param hints */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide flex-1">
|
||||
Param hints <span className="text-slate-400 font-normal">(optional)</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHint}
|
||||
className="text-2xs px-2 py-1 rounded border border-hairline text-slate-600 hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
+ Add hint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paramHints.map((hint, idx) => (
|
||||
<div key={idx} className="flex gap-2 items-start p-2 rounded-md border border-hairline bg-surface-2">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={hint.name}
|
||||
onChange={e => updateHint(idx, { name: e.target.value })}
|
||||
placeholder="param name"
|
||||
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={hint.valueToReplace}
|
||||
onChange={e => updateHint(idx, { valueToReplace: e.target.value })}
|
||||
placeholder="value to replace (literal)"
|
||||
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<select
|
||||
value={hint.type}
|
||||
onChange={e => updateHint(idx, { type: e.target.value as ParamHint['type'] })}
|
||||
className="px-2 py-1 rounded border border-hairline text-2xs bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHint(idx)}
|
||||
aria-label="Remove hint"
|
||||
className="mt-1 w-5 h-5 flex-shrink-0 flex items-center justify-center rounded hover:bg-red-100 hover:text-red-600 text-slate-400 transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overwrite checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwrite}
|
||||
onChange={e => { setOverwrite(e.target.checked); setConflictError(null); }}
|
||||
className="rounded border-hairline accent-accent"
|
||||
/>
|
||||
<span className="text-xs text-slate-700">Overwrite if script already exists</span>
|
||||
</label>
|
||||
|
||||
{/* Errors */}
|
||||
{(validationError || conflictError || submitError) && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 border border-red-200 text-xs text-red-700">
|
||||
{validationError ?? conflictError ?? submitError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-hairline bg-surface-2/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-medium text-slate-700 hover:bg-surface-2 disabled:opacity-50 transition-colors border border-hairline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form=""
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Compiling…' : 'Save as Script'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* ScriptDiffReview — side-by-side diff view for a pending .next.js patch.
|
||||
*
|
||||
* Design choice: .next.js files appear as sibling rows in the FileTree just like
|
||||
* any other file. Clicking a .next.js file opens this component (instead of
|
||||
* MonacoFileEditor). The diff view shows the current .js on the left (original)
|
||||
* and the candidate .next.js on the right (modified), read-only.
|
||||
*
|
||||
* Accept: archives scripts/{name}.js to trash, renames .next.js into place.
|
||||
* Reject: moves .next.js to trash; original is untouched.
|
||||
* Both actions invalidate the scripts listing and navigate back to scripts/{name}.js.
|
||||
*/
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
interface ScriptDiffReviewProps {
|
||||
/** The bare script name without extension, e.g. "myscript" */
|
||||
scriptName: string;
|
||||
onClose: (acceptedScript?: string) => void;
|
||||
showToast?: ShowToast;
|
||||
}
|
||||
|
||||
interface DiffResponse {
|
||||
current: string | null;
|
||||
candidate: string;
|
||||
candidateMtime: string;
|
||||
}
|
||||
|
||||
async function fetchDiff(name: string): Promise<DiffResponse> {
|
||||
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/diff`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.status === 404) throw new Error('No pending patch found.');
|
||||
if (!res.ok) throw new Error(`Diff fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<DiffResponse>;
|
||||
}
|
||||
|
||||
async function postAccept(name: string): Promise<void> {
|
||||
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/accept`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Accept failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function postReject(name: string): Promise<void> {
|
||||
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/reject`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Reject failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export function ScriptDiffReview({ scriptName, onClose, showToast }: ScriptDiffReviewProps) {
|
||||
const qc = useQueryClient();
|
||||
const notifyError = (label: string, err: unknown) => {
|
||||
const msg = `${label}: ${err instanceof Error ? err.message : 'Unknown error'}`;
|
||||
if (showToast) showToast(msg, 'error');
|
||||
else console.error(msg);
|
||||
};
|
||||
|
||||
const diffQuery = useQuery<DiffResponse, Error>({
|
||||
queryKey: ['userfolder', 'diff', scriptName],
|
||||
queryFn: () => fetchDiff(scriptName),
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const candidateMtimeLabel = diffQuery.data?.candidateMtime
|
||||
? new Date(diffQuery.data.candidateMtime).toLocaleString()
|
||||
: '';
|
||||
|
||||
async function handleAccept() {
|
||||
try {
|
||||
await postAccept(scriptName);
|
||||
// Invalidate browser-macros listing so the .next.js row disappears
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
|
||||
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
|
||||
// Navigate back to the now-accepted script
|
||||
onClose(`${scriptName}.js`);
|
||||
} catch (err) {
|
||||
notifyError('Accept failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
try {
|
||||
await postReject(scriptName);
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
|
||||
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
|
||||
// Navigate back to the original (unchanged) script if it exists; otherwise close
|
||||
const hasOriginal = diffQuery.data?.current !== null;
|
||||
onClose(hasOriginal ? `${scriptName}.js` : undefined);
|
||||
} catch (err) {
|
||||
notifyError('Reject failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-2.5 border-b border-hairline bg-surface-2/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold text-slate-700">Patch review: </span>
|
||||
<span className="font-mono text-xs text-slate-600">{scriptName}.next.js</span>
|
||||
{candidateMtimeLabel && (
|
||||
<span className="ml-2 text-2xs text-slate-400">{candidateMtimeLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs text-slate-400 font-mono">original → patch</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReject}
|
||||
disabled={diffQuery.isLoading}
|
||||
className="px-3 py-1 rounded-md text-2xs font-semibold bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
disabled={diffQuery.isLoading}
|
||||
className="px-3 py-1 rounded-md text-2xs font-semibold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{diffQuery.isLoading && (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
|
||||
Loading diff…
|
||||
</div>
|
||||
)}
|
||||
{diffQuery.isError && (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
|
||||
{diffQuery.error?.message ?? 'Failed to load diff.'}
|
||||
</div>
|
||||
)}
|
||||
{diffQuery.data && (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language="javascript"
|
||||
original={diffQuery.data.current ?? ''}
|
||||
modified={diffQuery.data.candidate}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SkillsForm } from '../settings/SkillsForm';
|
||||
|
||||
export function SkillsPanel() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<SkillsForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { SshConnection } from '../../lib/ssh-types';
|
||||
|
||||
interface SshConnectionFormProps {
|
||||
/** Existing connection for edit; null for create. */
|
||||
existing: SshConnection | null;
|
||||
/** True when rendered in admin context — exposes admin-only flags. */
|
||||
adminContext: boolean;
|
||||
/** Submit handler; receives the request body and returns a Promise. */
|
||||
onSubmit: (body: Record<string, unknown>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type KeypairSource = 'provided' | 'generate';
|
||||
type GeneratedKeyType = 'ed25519' | 'rsa-4096';
|
||||
|
||||
interface FormState {
|
||||
label: string;
|
||||
host: string;
|
||||
port: string;
|
||||
username: string;
|
||||
keypairSource: KeypairSource;
|
||||
generateKeyType: GeneratedKeyType;
|
||||
privateKeyPem: string;
|
||||
passphrase: string;
|
||||
remotePathPrefix: string;
|
||||
commandDenyPatterns: string;
|
||||
commandAllowPatterns: string;
|
||||
allowRemoteUnrestricted: boolean;
|
||||
allowPrivateAddresses: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function initialFromExisting(existing: SshConnection | null): FormState {
|
||||
return {
|
||||
label: existing?.label ?? '',
|
||||
host: existing?.host ?? '',
|
||||
port: existing ? String(existing.port) : '22',
|
||||
username: existing?.username ?? '',
|
||||
keypairSource: 'provided',
|
||||
generateKeyType: 'ed25519',
|
||||
privateKeyPem: '',
|
||||
passphrase: '',
|
||||
remotePathPrefix: existing?.remotePathPrefix ?? '/srv/agent',
|
||||
commandDenyPatterns: existing?.commandDenyPatterns ?? '',
|
||||
commandAllowPatterns: existing?.commandAllowPatterns ?? '',
|
||||
allowRemoteUnrestricted: existing?.allowRemoteUnrestricted ?? false,
|
||||
allowPrivateAddresses: existing?.allowPrivateAddresses ?? false,
|
||||
reason: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/edit form for an SSH connection.
|
||||
*
|
||||
* - Create: `existing === null`, all required fields visible, privateKeyPem required.
|
||||
* - Edit: `existing !== null`, privateKeyPem optional (omitted = keep current key).
|
||||
* - Admin context shows `allowRemoteUnrestricted` and `allowPrivateAddresses` toggles.
|
||||
* These flags are admin-only at the API layer; user-context renders them omitted.
|
||||
* - When `adminContext && isCreate`: also collect `reason` (required ≥ 8 chars) for the
|
||||
* audit row that POST /admin/globals will write. Edit reasons go via PATCH.
|
||||
*/
|
||||
export function SshConnectionForm({ existing, adminContext, onSubmit, onCancel }: SshConnectionFormProps) {
|
||||
const isCreate = existing === null;
|
||||
const [state, setState] = useState<FormState>(() => initialFromExisting(existing));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setState(initialFromExisting(existing));
|
||||
setError(null);
|
||||
}, [existing?.id]);
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setState(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const portNum = Number(state.port);
|
||||
const portValid = Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535;
|
||||
const remotePathOk = state.allowRemoteUnrestricted
|
||||
? true
|
||||
: (() => {
|
||||
const p = state.remotePathPrefix.trim();
|
||||
if (p.length === 0) return false;
|
||||
// Accept POSIX (`/srv/agent`), Windows drive (`C:\Users\agent`),
|
||||
// UNC (`\\server\share`), or no-leading-slash prefixes. Reject any
|
||||
// `..` parent-ref segment in either separator.
|
||||
return !p.split(/[\\/]/).includes('..');
|
||||
})();
|
||||
const needsUploadedKey = isCreate && state.keypairSource === 'provided';
|
||||
const baseValid =
|
||||
state.label.trim().length > 0 &&
|
||||
state.host.trim().length > 0 &&
|
||||
state.username.trim().length > 0 &&
|
||||
portValid &&
|
||||
remotePathOk &&
|
||||
(!needsUploadedKey || state.privateKeyPem.length > 0);
|
||||
const reasonNeeded = adminContext;
|
||||
const reasonValid = !reasonNeeded || state.reason.trim().length >= 8;
|
||||
const canSubmit = baseValid && reasonValid && !submitting;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
label: state.label.trim(),
|
||||
host: state.host.trim(),
|
||||
port: portNum,
|
||||
username: state.username.trim(),
|
||||
};
|
||||
// Keypair handling. Only meaningful at create-time; on edit we never
|
||||
// re-key (a separate rotation flow handles that).
|
||||
if (isCreate && state.keypairSource === 'generate') {
|
||||
body.keypairSource = 'generate';
|
||||
body.generateKeyType = state.generateKeyType;
|
||||
} else {
|
||||
if (state.privateKeyPem.length > 0) {
|
||||
body.privateKeyPem = state.privateKeyPem;
|
||||
}
|
||||
if (state.passphrase.length > 0) {
|
||||
body.passphrase = state.passphrase;
|
||||
}
|
||||
}
|
||||
if (state.allowRemoteUnrestricted && adminContext) {
|
||||
body.allowRemoteUnrestricted = true;
|
||||
} else {
|
||||
body.remotePathPrefix = state.remotePathPrefix.trim();
|
||||
}
|
||||
if (adminContext && state.allowPrivateAddresses) {
|
||||
body.allowPrivateAddresses = true;
|
||||
}
|
||||
if (state.commandDenyPatterns.trim().length > 0) {
|
||||
body.commandDenyPatterns = state.commandDenyPatterns.trim();
|
||||
} else if (existing?.commandDenyPatterns) {
|
||||
body.commandDenyPatterns = '';
|
||||
}
|
||||
if (state.commandAllowPatterns.trim().length > 0) {
|
||||
body.commandAllowPatterns = state.commandAllowPatterns.trim();
|
||||
} else if (existing?.commandAllowPatterns) {
|
||||
body.commandAllowPatterns = '';
|
||||
}
|
||||
if (reasonNeeded) {
|
||||
body.reason = state.reason.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(body);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Label" required>
|
||||
<input
|
||||
type="text"
|
||||
value={state.label}
|
||||
onChange={e => update('label', e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="prod-db (任意の表示名)"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Username" required>
|
||||
<input
|
||||
type="text"
|
||||
value={state.username}
|
||||
onChange={e => update('username', e.target.value)}
|
||||
className={inputCls + ' font-mono'}
|
||||
placeholder="agent"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Host" required>
|
||||
<input
|
||||
type="text"
|
||||
value={state.host}
|
||||
onChange={e => update('host', e.target.value)}
|
||||
className={inputCls + ' font-mono'}
|
||||
placeholder="db.example.com"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Port" required>
|
||||
<input
|
||||
type="number"
|
||||
value={state.port}
|
||||
onChange={e => update('port', e.target.value)}
|
||||
className={inputCls + ' font-mono'}
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{isCreate && (
|
||||
<fieldset className="rounded border border-hairline bg-surface/40 p-3 space-y-2">
|
||||
<legend className="px-1 text-2xs font-semibold text-slate-600 uppercase tracking-wide">
|
||||
鍵の出所
|
||||
</legend>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="keypairSource"
|
||||
value="provided"
|
||||
checked={state.keypairSource === 'provided'}
|
||||
onChange={() => update('keypairSource', 'provided')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">既存の秘密鍵を貼り付け</span>
|
||||
<span className="block text-2xs text-slate-600">
|
||||
ローカルで <code className="font-mono">ssh-keygen</code> して作成済みの鍵を upload します。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="keypairSource"
|
||||
value="generate"
|
||||
checked={state.keypairSource === 'generate'}
|
||||
onChange={() => update('keypairSource', 'generate')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">Orchestrator で新規生成</span>
|
||||
<span className="block text-2xs text-slate-600">
|
||||
秘密鍵は Orchestrator が保持し、作成後に公開鍵が表示されます。それを接続先の
|
||||
<code className="font-mono"> ~/.ssh/authorized_keys</code> に追加してください。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{state.keypairSource === 'generate' && (
|
||||
<Field label="Key type">
|
||||
<select
|
||||
value={state.generateKeyType}
|
||||
onChange={e => update('generateKeyType', e.target.value as GeneratedKeyType)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="ed25519">Ed25519 (recommended, 推奨)</option>
|
||||
<option value="rsa-4096">RSA 4096-bit (互換性重視)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{(!isCreate || state.keypairSource === 'provided') && (
|
||||
<>
|
||||
<Field
|
||||
label={`Private Key (PEM)${isCreate ? '' : ' ← 空欄なら現在のキーを維持'}`}
|
||||
required={isCreate && state.keypairSource === 'provided'}
|
||||
>
|
||||
<textarea
|
||||
value={state.privateKeyPem}
|
||||
onChange={e => update('privateKeyPem', e.target.value)}
|
||||
className={inputCls + ' font-mono h-32 resize-y'}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
required={isCreate && state.keypairSource === 'provided'}
|
||||
/>
|
||||
<p className="text-2xs text-slate-500 mt-1">
|
||||
サーバー側で保存時に AES-256-GCM 暗号化されます。SHA1 ベースの RSA キーは検出時に reject されます。
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field label="Passphrase ← キーが encrypted な場合のみ">
|
||||
<input
|
||||
type="password"
|
||||
value={state.passphrase}
|
||||
onChange={e => update('passphrase', e.target.value)}
|
||||
className={inputCls + ' font-mono'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!state.allowRemoteUnrestricted && (
|
||||
<Field label="Remote Path Prefix" required>
|
||||
<input
|
||||
type="text"
|
||||
value={state.remotePathPrefix}
|
||||
onChange={e => update('remotePathPrefix', e.target.value)}
|
||||
className={inputCls + ' font-mono'}
|
||||
placeholder="/srv/agent"
|
||||
required
|
||||
/>
|
||||
<p className="text-2xs text-slate-500 mt-1">
|
||||
ファイル系オペレーション (Upload/Download) はこのプレフィックス配下に限定されます。
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Command Deny Patterns (1 行 1 regex)">
|
||||
<textarea
|
||||
value={state.commandDenyPatterns}
|
||||
onChange={e => update('commandDenyPatterns', e.target.value)}
|
||||
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
|
||||
placeholder="^rm\s+-rf\s+/ ^dd\s"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Command Allow Patterns (空 = 全許可)">
|
||||
<textarea
|
||||
value={state.commandAllowPatterns}
|
||||
onChange={e => update('commandAllowPatterns', e.target.value)}
|
||||
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
|
||||
placeholder="^psql\s ^ls\b"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{adminContext && (
|
||||
<fieldset className="rounded border border-amber-200 bg-amber-50/50 p-3">
|
||||
<legend className="px-1 text-2xs font-semibold text-amber-800 uppercase tracking-wide">
|
||||
Admin-only flags
|
||||
</legend>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.allowRemoteUnrestricted}
|
||||
onChange={e => update('allowRemoteUnrestricted', e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">Allow remote unrestricted</span>
|
||||
<span className="block text-2xs text-slate-600">
|
||||
Remote Path Prefix を無効化。本当に必要な場合のみ。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.allowPrivateAddresses}
|
||||
onChange={e => update('allowPrivateAddresses', e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">Allow private addresses</span>
|
||||
<span className="block text-2xs text-slate-600">
|
||||
RFC1918 / localhost への接続を許可。SSRF ガードを緩める設定。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{reasonNeeded && (
|
||||
<Field label="Reason (≥ 8 chars; 監査ログに残ります)" required>
|
||||
<input
|
||||
type="text"
|
||||
value={state.reason}
|
||||
onChange={e => update('reason', e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="新規ステージング用 global 接続を追加"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-hairline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '保存中…' : isCreate ? '作成' : '更新'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls = 'w-full text-xs px-2 py-1.5 border border-hairline rounded';
|
||||
|
||||
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SshConnection, TestResponse } from '../../lib/ssh-types';
|
||||
import { SshConnectionForm } from './SshConnectionForm';
|
||||
import { SshHostKeyDialog } from './SshHostKeyDialog';
|
||||
import { SshPublicKeyDialog } from './SshPublicKeyDialog';
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: SshConnection[];
|
||||
}
|
||||
|
||||
interface CreateResponse {
|
||||
connection: SshConnection;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
|
||||
const res = await fetch('/api/ssh/connections', { credentials: 'include' });
|
||||
if (res.status === 404) {
|
||||
return { list: [], sshDisabled: true };
|
||||
}
|
||||
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
||||
const data: ConnectionsResponse = await res.json();
|
||||
return { list: data.connections ?? [], sshDisabled: false };
|
||||
}
|
||||
|
||||
async function apiCreate(body: Record<string, unknown>): Promise<CreateResponse> {
|
||||
const res = await fetch('/api/ssh/connections', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
return (await res.json()) as CreateResponse;
|
||||
}
|
||||
|
||||
async function apiGetPublicKey(id: string): Promise<string | null> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
const json = (await res.json()) as { publicKey?: string | null };
|
||||
return json.publicKey ?? null;
|
||||
}
|
||||
|
||||
async function apiPatch(id: string, body: Record<string, unknown>): Promise<SshConnection> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
const json = (await res.json()) as { connection: SshConnection };
|
||||
return json.connection;
|
||||
}
|
||||
|
||||
async function apiDelete(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
}
|
||||
|
||||
async function apiTest(id: string): Promise<TestResponse> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/test`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
return (await res.json()) as TestResponse;
|
||||
}
|
||||
|
||||
async function apiVerifyHostKey(
|
||||
id: string,
|
||||
body: { fingerprint: string; token: string; reason?: string },
|
||||
): Promise<void> {
|
||||
const endpoint = body.reason ? 'replace-host-key' : 'verify-host-key';
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
}
|
||||
|
||||
function parseApiError(rawText: string, status: number): string {
|
||||
try {
|
||||
const parsed = JSON.parse(rawText);
|
||||
if (parsed?.error) {
|
||||
return parsed.detail ? `${parsed.error}: ${typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)}` : parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
interface SshConnectionsPanelProps {
|
||||
/** Render personal+globals (user mode) or only globals via admin endpoints. */
|
||||
scope?: 'user';
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}) {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['ssh', 'connections'],
|
||||
queryFn: fetchConnections,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null);
|
||||
const [pubKeyDialog, setPubKeyDialog] = useState<{
|
||||
publicKey: string;
|
||||
label?: string;
|
||||
freshlyGenerated: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: apiCreate,
|
||||
onSuccess: (resp) => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
setCreating(false);
|
||||
showToast?.('SSH 接続を作成しました', 'success');
|
||||
// If the server returned a public key (always for keypairSource=generate;
|
||||
// also surfaced for provided keys), open the dialog so the user can
|
||||
// copy it into authorized_keys.
|
||||
if (resp.publicKey) {
|
||||
setPubKeyDialog({
|
||||
publicKey: resp.publicKey,
|
||||
label: resp.connection.label,
|
||||
freshlyGenerated: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const showPubKeyMutation = useMutation({
|
||||
mutationFn: async ({ id, label }: { id: string; label: string }) => {
|
||||
const pk = await apiGetPublicKey(id);
|
||||
return { publicKey: pk, label };
|
||||
},
|
||||
onSuccess: ({ publicKey, label }) => {
|
||||
if (publicKey) {
|
||||
setPubKeyDialog({ publicKey, label, freshlyGenerated: false });
|
||||
} else {
|
||||
showToast?.('公開鍵の取得に失敗しました', 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : '公開鍵取得失敗', 'error');
|
||||
},
|
||||
});
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) => apiPatch(id, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
setEditingId(null);
|
||||
showToast?.('SSH 接続を更新しました', 'success');
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: apiDelete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
showToast?.('SSH 接続を削除しました', 'success');
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : '削除失敗', 'error');
|
||||
},
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: apiTest,
|
||||
onSuccess: (response, id) => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
// Surface result. pass = already verified; first_observe/mismatch = needs confirm.
|
||||
if (response.verdict === 'pass') {
|
||||
showToast?.(`ホストキーは一致しています (${response.fingerprint.slice(0, 20)}…)`, 'success');
|
||||
} else if (response.verdict === 'first_observe' || response.verdict === 'mismatch') {
|
||||
setTestResult({ id, test: response, replaceMode: response.verdict === 'mismatch' });
|
||||
} else if (response.verdict === 'alg_not_allowed') {
|
||||
showToast?.('ホストキーのアルゴリズムが許可リストにありません', 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : 'テスト失敗', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
async function handleVerifyHostKey(connId: string, args: { fingerprint: string; token: string; reason?: string }) {
|
||||
await apiVerifyHostKey(connId, args);
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
showToast?.('ホストキーを検証しました', 'success');
|
||||
}
|
||||
|
||||
if (data?.sshDisabled) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-2">SSH 接続</h2>
|
||||
<div className="text-xs text-slate-600 bg-surface border border-hairline rounded-md p-3 leading-relaxed">
|
||||
SSH サブシステムは無効です。<code className="font-mono">config.yaml</code> の{' '}
|
||||
<code className="font-mono">ssh.enabled: true</code> と環境変数{' '}
|
||||
<code className="font-mono">MCP_ENCRYPTION_KEY</code> 設定後にサーバーを再起動してください。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const owned = (data?.list ?? []).filter(c => c.ownerId !== null);
|
||||
const globals = (data?.list ?? []).filter(c => c.ownerId === null);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">SSH 接続</h2>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
エージェントの SshExec / SshUpload / SshDownload ツールから利用される SSH 接続を管理します。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreating(true); setEditingId(null); }}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
|
||||
disabled={creating}
|
||||
>
|
||||
+ 新規作成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-xs text-slate-400">Loading…</div>}
|
||||
{error && <div className="text-xs text-red-500">読み込みに失敗しました: {String(error)}</div>}
|
||||
|
||||
{creating && (
|
||||
<section className="mb-5 border border-accent/40 rounded-md bg-white p-4">
|
||||
<h3 className="text-xs font-semibold text-slate-700 mb-2">新規 SSH 接続</h3>
|
||||
<SshConnectionForm
|
||||
existing={null}
|
||||
adminContext={false}
|
||||
onSubmit={async (body) => { await createMutation.mutateAsync(body); }}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<SectionHeader title="自分の接続" count={owned.length} />
|
||||
{owned.length === 0 && !creating && (
|
||||
<div className="text-xs text-slate-400 px-3 py-4">
|
||||
まだ接続がありません。「+ 新規作成」から登録してください。
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-hairline mb-6">
|
||||
{owned.map(c => (
|
||||
<ConnectionRow
|
||||
key={c.id}
|
||||
connection={c}
|
||||
isOwner
|
||||
editing={editingId === c.id}
|
||||
onEdit={() => { setEditingId(c.id); setCreating(false); }}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
onPatch={async (body) => { await patchMutation.mutateAsync({ id: c.id, body }); }}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`接続 "${c.label}" を削除しますか?`)) {
|
||||
deleteMutation.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
onTest={() => testMutation.mutate(c.id)}
|
||||
testing={testMutation.isPending && testMutation.variables === c.id}
|
||||
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
||||
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{globals.length > 0 && (
|
||||
<>
|
||||
<SectionHeader title="グローバル接続 (info-only)" count={globals.length} />
|
||||
<p className="text-2xs text-slate-500 px-3 mb-2">
|
||||
管理者が登録した共有接続です。ピース側で grant が設定されている場合のみツールから利用できます。
|
||||
</p>
|
||||
<ul className="divide-y divide-hairline">
|
||||
{globals.map(c => (
|
||||
<ConnectionRow
|
||||
key={c.id}
|
||||
connection={c}
|
||||
isOwner={false}
|
||||
editing={false}
|
||||
onEdit={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onPatch={async () => {}}
|
||||
onDelete={() => {}}
|
||||
onTest={() => testMutation.mutate(c.id)}
|
||||
testing={testMutation.isPending && testMutation.variables === c.id}
|
||||
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
||||
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<SshHostKeyDialog
|
||||
test={testResult.test}
|
||||
replaceMode={testResult.replaceMode}
|
||||
onClose={() => setTestResult(null)}
|
||||
onVerify={(args) => handleVerifyHostKey(testResult.id, args)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pubKeyDialog && (
|
||||
<SshPublicKeyDialog
|
||||
publicKey={pubKeyDialog.publicKey}
|
||||
label={pubKeyDialog.label}
|
||||
freshlyGenerated={pubKeyDialog.freshlyGenerated}
|
||||
onClose={() => setPubKeyDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-exported for use by other panels that render ConnectionRow. */
|
||||
export type ShowPublicKeyHandler = (args: { id: string; label: string }) => void;
|
||||
|
||||
function SectionHeader({ title, count }: { title: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<span>{title}</span>
|
||||
<span className="text-slate-400 font-mono">({count})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionRowProps {
|
||||
connection: SshConnection;
|
||||
isOwner: boolean;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onPatch: (body: Record<string, unknown>) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
onShowPublicKey: () => void;
|
||||
showingPublicKey: boolean;
|
||||
}
|
||||
|
||||
function ConnectionRow(props: ConnectionRowProps) {
|
||||
const {
|
||||
connection: c, isOwner, editing,
|
||||
onEdit, onCancelEdit, onPatch, onDelete, onTest, testing,
|
||||
onShowPublicKey, showingPublicKey,
|
||||
} = props;
|
||||
const verified = c.hostKeyVerifiedAt !== null;
|
||||
const pending = c.hostKeyPending;
|
||||
const disabled = c.disabledByAdmin || !c.enabled;
|
||||
|
||||
return (
|
||||
<li className="py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-slate-900 truncate">{c.label}</span>
|
||||
<ScopeBadge owner={c.ownerId} />
|
||||
<HostKeyBadge verified={verified} pending={pending} />
|
||||
{disabled && <Badge color="red">{c.disabledByAdmin ? 'admin-disabled' : 'disabled'}</Badge>}
|
||||
{c.allowRemoteUnrestricted && <Badge color="amber">remote: unrestricted</Badge>}
|
||||
{c.allowPrivateAddresses && <Badge color="amber">private addrs</Badge>}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-600 font-mono mt-1 truncate">
|
||||
{c.username}@{c.host}:{c.port}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 mt-0.5">
|
||||
id: <CopyableUuid value={c.id} />
|
||||
{' · '}path-prefix: <span className="font-mono">{c.remotePathPrefix}</span>
|
||||
{c.keyFingerprint && (
|
||||
<>
|
||||
{' · '}key fp: <span className="font-mono">{c.keyFingerprint.slice(0, 24)}…</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{c.disabledByAdminReason && (
|
||||
<div className="text-2xs text-red-700 mt-0.5">理由: {c.disabledByAdminReason}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{testing ? 'テスト中…' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowPublicKey}
|
||||
disabled={showingPublicKey}
|
||||
title="authorized_keys に貼る公開鍵を表示"
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{showingPublicKey ? '取得中…' : '公開鍵'}
|
||||
</button>
|
||||
{isOwner && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface"
|
||||
>
|
||||
編集
|
||||
</button>
|
||||
)}
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="px-2 h-7 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="mt-3 ml-1 pl-3 border-l-2 border-accent/30">
|
||||
<SshConnectionForm
|
||||
existing={c}
|
||||
adminContext={false}
|
||||
onSubmit={async (body) => { await onPatch(body); }}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a connection UUID with a click-to-copy action. The full UUID is
|
||||
* shown inline so agents that ask "give me the connection_id" can be
|
||||
* answered by selecting/copying without opening any sub-view.
|
||||
*/
|
||||
function CopyableUuid({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API can fail in non-secure contexts; user can still select manually.
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={`クリックで UUID をコピー: ${value}`}
|
||||
className="font-mono hover:underline cursor-pointer text-slate-600 hover:text-accent-deep"
|
||||
>
|
||||
{copied ? '✓ コピーしました' : value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeBadge({ owner }: { owner: string | null }) {
|
||||
return owner === null ? (
|
||||
<Badge color="slate">global</Badge>
|
||||
) : (
|
||||
<Badge color="blue">personal</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function HostKeyBadge({ verified, pending }: { verified: boolean; pending: boolean }) {
|
||||
if (pending) return <Badge color="amber">host-key pending</Badge>;
|
||||
if (verified) return <Badge color="emerald">host-key verified</Badge>;
|
||||
return <Badge color="slate">host-key untested</Badge>;
|
||||
}
|
||||
|
||||
function Badge({ color, children }: { color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
|
||||
const cls: Record<typeof color, string> = {
|
||||
slate: 'bg-slate-100 text-slate-600',
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
emerald: 'bg-emerald-50 text-emerald-700',
|
||||
amber: 'bg-amber-50 text-amber-700',
|
||||
red: 'bg-red-50 text-red-700',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${cls[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import type { TestResponse } from '../../lib/ssh-types';
|
||||
|
||||
interface SshHostKeyDialogProps {
|
||||
/** Test response that produced this dialog (contains verdict + fingerprint + token). */
|
||||
test: TestResponse;
|
||||
/** True when an existing verified host key already differs from the observed one. */
|
||||
replaceMode: boolean;
|
||||
onClose: () => void;
|
||||
onVerify: (args: { fingerprint: string; token: string; reason?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown after POST /test returns a pending verdict (first_observe or mismatch).
|
||||
* The user must confirm the observed fingerprint to persist it as the verified
|
||||
* host key. For mismatch the API requires a `reason` (≥ 8 chars) and routes
|
||||
* through /replace-host-key, which writes a ssh.connection.host_key.replace
|
||||
* audit row.
|
||||
*/
|
||||
export function SshHostKeyDialog({ test, replaceMode, onClose, onVerify }: SshHostKeyDialogProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (test.pendingToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const needsReason = replaceMode || test.verdict === 'mismatch';
|
||||
const reasonValid = !needsReason || reason.trim().length >= 8;
|
||||
|
||||
async function handleConfirm() {
|
||||
if (test.pendingToken === null) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onVerify({
|
||||
fingerprint: test.fingerprint,
|
||||
token: test.pendingToken,
|
||||
reason: needsReason ? reason.trim() : undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-lg bg-white rounded-md shadow-lg border border-hairline overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-hairline">
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{test.verdict === 'first_observe' ? 'ホストキーを記録' : 'ホストキーを置き換え'}
|
||||
</h3>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
{test.verdict === 'first_observe'
|
||||
? '初回観測したホストキーを確認してください。'
|
||||
: '⚠️ 記録済みのホストキーと異なります。MITM の可能性も含めて慎重に確認してください。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
|
||||
Fingerprint
|
||||
</div>
|
||||
<div className="font-mono text-xs text-slate-900 bg-surface px-2 py-1.5 rounded border border-hairline break-all select-all">
|
||||
{test.fingerprint}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
|
||||
Host Key Type
|
||||
</div>
|
||||
<div className="font-mono text-xs text-slate-700">{test.hostKeyType}</div>
|
||||
</div>
|
||||
{needsReason && (
|
||||
<div>
|
||||
<label className="block text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
|
||||
Reason (≥ 8 chars)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
className="w-full text-xs px-2 py-1.5 border border-hairline rounded font-mono"
|
||||
placeholder="ホスト OS 再構築のため差し替え"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-red-600">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting || !reasonValid}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '保存中…' : test.verdict === 'first_observe' ? '記録する' : '置き換える'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
publicKey: string;
|
||||
/** Optional label shown in the title (e.g. the connection's display name). */
|
||||
label?: string;
|
||||
/** When true, surface a "newly generated" callout. Default false. */
|
||||
freshlyGenerated?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that shows the OpenSSH-format public key (`ssh-ed25519 AAAA...`)
|
||||
* with a one-click copy button. Used both:
|
||||
* - after creating a connection with `keypairSource=generate` (so the user
|
||||
* can paste the key into the remote `authorized_keys`), and
|
||||
* - on demand from any existing connection (so the user can re-verify
|
||||
* that the registered authorized_keys entry matches our stored key).
|
||||
*/
|
||||
export function SshPublicKeyDialog({ publicKey, label, freshlyGenerated, onClose }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicKey);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API can fail in non-secure contexts; ignore and let the
|
||||
// user select the textarea manually.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-2xl bg-white rounded-md shadow-lg border border-hairline overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-hairline bg-surface/40">
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
公開鍵 {label && <span className="font-normal text-slate-500">— {label}</span>}
|
||||
</h3>
|
||||
{freshlyGenerated && (
|
||||
<p className="text-2xs text-emerald-700 mt-1">
|
||||
新しい鍵を Orchestrator で生成しました。下の公開鍵を接続先の
|
||||
<code className="font-mono"> ~/.ssh/authorized_keys</code> に追加してください。
|
||||
</p>
|
||||
)}
|
||||
{!freshlyGenerated && (
|
||||
<p className="text-2xs text-slate-600 mt-1">
|
||||
この接続が保持している秘密鍵に対応する公開鍵です。接続先の
|
||||
<code className="font-mono"> ~/.ssh/authorized_keys</code> に同じ行が登録されていない場合、
|
||||
認証は失敗します。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<label className="block text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
|
||||
authorized_keys に貼る 1 行
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={publicKey}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
className="w-full text-xs font-mono px-2 py-1.5 border border-hairline rounded h-24 resize-y bg-surface/40"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-2xs text-slate-500">
|
||||
SSH 接続テストで「All configured authentication methods failed」が出る場合は、まずこの値が
|
||||
接続先の <code className="font-mono">authorized_keys</code> に登録されているかを確認してください。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep ml-2 flex-shrink-0"
|
||||
>
|
||||
{copied ? 'コピーしました' : 'コピー'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end bg-surface/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface"
|
||||
>
|
||||
閉じる
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
|
||||
interface Subscription {
|
||||
consumer_user_id: string;
|
||||
publisher_user_id: string;
|
||||
folder: string;
|
||||
mode: 'search' | 'inject';
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
interface DiscoverRow {
|
||||
owner_id: string;
|
||||
folder: string;
|
||||
file_name: string;
|
||||
title: string | null;
|
||||
visibility: string;
|
||||
mode_hint: string | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface InjectItem {
|
||||
owner_id: string;
|
||||
folder: string;
|
||||
file_name: string;
|
||||
size_kb: number;
|
||||
}
|
||||
|
||||
interface InjectPreview {
|
||||
items: InjectItem[];
|
||||
total_kb: number;
|
||||
budget_kb: number;
|
||||
per_note_max_kb: number;
|
||||
}
|
||||
|
||||
function NotesListExpanded({
|
||||
ownerId,
|
||||
folder,
|
||||
onSelectNote,
|
||||
}: {
|
||||
ownerId: string;
|
||||
folder: string;
|
||||
onSelectNote: (fileName: string) => void;
|
||||
}) {
|
||||
const list = useQuery<{ rows: DiscoverRow[] }>({
|
||||
queryKey: ['notes-folder-list', ownerId, folder],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(
|
||||
`/api/notes/discover?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&limit=200`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
return r.json();
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (list.isLoading) return <p className="text-2xs text-slate-400 pl-3 py-1">Loading…</p>;
|
||||
if (list.isError) return <p className="text-2xs text-red-500 pl-3 py-1">Failed to load notes.</p>;
|
||||
const rows = list.data?.rows ?? [];
|
||||
if (rows.length === 0) return <p className="text-2xs text-slate-400 pl-3 py-1">(空)</p>;
|
||||
|
||||
return (
|
||||
<ul className="pl-3 pt-1 pb-1 space-y-0.5">
|
||||
{rows.map((n) => (
|
||||
<li key={n.file_name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectNote(n.file_name)}
|
||||
className="w-full text-left flex items-center gap-2 px-2 py-1 rounded text-2xs hover:bg-surface-2/60 transition-colors"
|
||||
title={`${n.owner_id}/${n.folder}/${n.file_name}`}
|
||||
>
|
||||
<span className="text-slate-400 flex-shrink-0">📄</span>
|
||||
<span className="flex-1 min-w-0 truncate text-slate-700">
|
||||
{n.title || <span className="font-mono text-slate-500">{n.file_name}</span>}
|
||||
</span>
|
||||
{n.mode_hint && (
|
||||
<span className="text-slate-400 text-[10px] font-mono">{n.mode_hint}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteContentModal({
|
||||
ownerId,
|
||||
folder,
|
||||
fileName,
|
||||
onClose,
|
||||
}: {
|
||||
ownerId: string;
|
||||
folder: string;
|
||||
fileName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const note = useQuery<{ fm: Record<string, unknown>; body: string; content: string }>({
|
||||
queryKey: ['notes-cross-user-file', ownerId, folder, fileName],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(
|
||||
`/api/notes/file?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&file_name=${encodeURIComponent(fileName)}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
return r.json();
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const title = (note.data?.fm.title as string | undefined) || fileName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-hairline px-4 py-3 flex-shrink-0">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[13px] font-semibold text-slate-900 truncate">{title}</h3>
|
||||
<p className="text-2xs text-slate-500 font-mono truncate">
|
||||
{ownerId}/{folder}/{fileName}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="閉じる"
|
||||
className="px-2 py-1 text-slate-500 hover:text-slate-800 rounded hover:bg-surface-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||
{note.isLoading && <p className="text-[13px] text-slate-400">Loading…</p>}
|
||||
{note.isError && <p className="text-[13px] text-red-500">読み込みに失敗しました。</p>}
|
||||
{note.data && note.data.body
|
||||
? <MarkdownText text={note.data.body} />
|
||||
: note.data && <p className="text-[13px] text-slate-400 italic">(本文なし)</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeSelect({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: 'search' | 'inject';
|
||||
onChange: (m: 'search' | 'inject') => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="border border-hairline rounded text-2xs px-1 py-0.5 bg-white focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={mode}
|
||||
onChange={(e) => onChange(e.target.value as 'search' | 'inject')}
|
||||
>
|
||||
<option value="search">search</option>
|
||||
<option value="inject">inject</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionsPanel({ currentUserId }: { currentUserId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [q, setQ] = useState('');
|
||||
// Track which (owner, folder) rows are expanded to show the notes list inline.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const toggleExpanded = (key: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Open-modal target for note content preview.
|
||||
const [openNote, setOpenNote] = useState<{ ownerId: string; folder: string; fileName: string } | null>(null);
|
||||
|
||||
const subs = useQuery<{ rows: Subscription[] }>({
|
||||
queryKey: ['notes-subscriptions'],
|
||||
queryFn: async () => {
|
||||
const r = await fetch('/api/notes/subscriptions', { credentials: 'include' });
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
return r.json();
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const discover = useQuery<{ rows: DiscoverRow[] }>({
|
||||
queryKey: ['notes-discover', q],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/notes/discover?q=${encodeURIComponent(q)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
return r.json();
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const preview = useQuery<InjectPreview>({
|
||||
queryKey: ['notes-inject-preview'],
|
||||
queryFn: async () => {
|
||||
const r = await fetch('/api/notes/inject-preview', { credentials: 'include' });
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
return r.json();
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const subscribe = useMutation({
|
||||
mutationFn: async ({
|
||||
publisher,
|
||||
folder,
|
||||
mode,
|
||||
}: {
|
||||
publisher: string;
|
||||
folder: string;
|
||||
mode: 'search' | 'inject';
|
||||
}) => {
|
||||
const r = await fetch('/api/notes/subscriptions', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ publisher_user_id: publisher, folder, mode, enabled: true }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({ error: 'failed' }));
|
||||
throw new Error((j as { error?: string }).error ?? 'failed');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
|
||||
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
|
||||
qc.invalidateQueries({ queryKey: ['notes-discover'] });
|
||||
},
|
||||
});
|
||||
|
||||
const unsubscribe = useMutation({
|
||||
mutationFn: async ({ publisher, folder }: { publisher: string; folder: string }) => {
|
||||
const r = await fetch(
|
||||
`/api/notes/subscriptions?publisher_user_id=${encodeURIComponent(publisher)}&folder=${encodeURIComponent(folder)}`,
|
||||
{ method: 'DELETE', credentials: 'include' },
|
||||
);
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
|
||||
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Group discover rows by (owner_id, folder)
|
||||
const folderGroups = new Map<
|
||||
string,
|
||||
{ owner_id: string; folder: string; count: number; visibility: string }
|
||||
>();
|
||||
for (const row of discover.data?.rows ?? []) {
|
||||
const key = `${row.owner_id}/${row.folder}`;
|
||||
const existing = folderGroups.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
folderGroups.set(key, {
|
||||
owner_id: row.owner_id,
|
||||
folder: row.folder,
|
||||
count: 1,
|
||||
visibility: row.visibility,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const myFolders = (subs.data?.rows ?? []).filter(
|
||||
(s) => s.publisher_user_id === currentUserId,
|
||||
);
|
||||
const otherSubs = (subs.data?.rows ?? []).filter(
|
||||
(s) => s.publisher_user_id !== currentUserId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-6 space-y-8">
|
||||
|
||||
{/* My Folders */}
|
||||
<section>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
|
||||
My Folders{' '}
|
||||
<span className="text-slate-400 font-normal">({myFolders.length})</span>
|
||||
</h3>
|
||||
{subs.isLoading && (
|
||||
<p className="text-[13px] text-slate-400">Loading…</p>
|
||||
)}
|
||||
{subs.isError && (
|
||||
<p className="text-[13px] text-red-500">Failed to load subscriptions.</p>
|
||||
)}
|
||||
{!subs.isLoading && !subs.isError && myFolders.length === 0 && (
|
||||
<p className="text-[13px] text-slate-400">まだ folder がありません</p>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{myFolders.map((s) => {
|
||||
const key = `${s.publisher_user_id}/${s.folder}`;
|
||||
const isOpen = expanded.has(key);
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(key)}
|
||||
aria-label={isOpen ? '折りたたむ' : '展開'}
|
||||
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
|
||||
>
|
||||
{isOpen ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="flex-1 text-[13px] font-mono text-slate-700">{s.folder}</span>
|
||||
<ModeSelect
|
||||
mode={s.mode}
|
||||
onChange={(m) =>
|
||||
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<NotesListExpanded
|
||||
ownerId={s.publisher_user_id}
|
||||
folder={s.folder}
|
||||
onSelectNote={(fileName) =>
|
||||
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* My Subscriptions */}
|
||||
<section>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
|
||||
My Subscriptions{' '}
|
||||
<span className="text-slate-400 font-normal">({otherSubs.length})</span>
|
||||
</h3>
|
||||
{!subs.isLoading && !subs.isError && otherSubs.length === 0 && (
|
||||
<p className="text-[13px] text-slate-400">購読中の他ユーザー folder はありません</p>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{otherSubs.map((s) => {
|
||||
const key = `${s.publisher_user_id}/${s.folder}`;
|
||||
const isOpen = expanded.has(key);
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(key)}
|
||||
aria-label={isOpen ? '折りたたむ' : '展開'}
|
||||
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
|
||||
>
|
||||
{isOpen ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="flex-1 text-[13px] font-mono text-slate-700">
|
||||
{s.publisher_user_id}/{s.folder}
|
||||
</span>
|
||||
<ModeSelect
|
||||
mode={s.mode}
|
||||
onChange={(m) =>
|
||||
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xs text-red-600 hover:text-red-800 font-medium px-2 py-0.5 rounded hover:bg-red-50 transition-colors"
|
||||
onClick={() =>
|
||||
unsubscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder })
|
||||
}
|
||||
disabled={unsubscribe.isPending}
|
||||
>
|
||||
Unsubscribe
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<NotesListExpanded
|
||||
ownerId={s.publisher_user_id}
|
||||
folder={s.folder}
|
||||
onSelectNote={(fileName) =>
|
||||
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{unsubscribe.isError && (
|
||||
<p className="mt-1 text-2xs text-red-600">{(unsubscribe.error as Error).message}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Discover */}
|
||||
<section>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">Discover</h3>
|
||||
<input
|
||||
className="border border-hairline rounded px-2 py-1.5 mb-3 w-full text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent placeholder:text-slate-400"
|
||||
placeholder="Search by title, tag, body…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
{discover.isLoading && (
|
||||
<p className="text-[13px] text-slate-400">Searching…</p>
|
||||
)}
|
||||
{discover.isError && (
|
||||
<p className="text-[13px] text-red-500">Search failed.</p>
|
||||
)}
|
||||
{!discover.isLoading && !discover.isError && folderGroups.size === 0 && (
|
||||
<p className="text-[13px] text-slate-400">
|
||||
{q ? '結果なし' : '検索してフォルダーを探してください'}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{Array.from(folderGroups.values()).map((g) => {
|
||||
const alreadySubbed = (subs.data?.rows ?? []).some(
|
||||
(s) => s.publisher_user_id === g.owner_id && s.folder === g.folder,
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={`${g.owner_id}/${g.folder}`}
|
||||
className="border border-hairline rounded-md px-3 py-2 bg-surface-2/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="flex-1 text-[13px] font-mono text-slate-700">
|
||||
{g.owner_id}/{g.folder}
|
||||
</span>
|
||||
<span className="text-2xs text-slate-400">
|
||||
{g.visibility} · {g.count} notes
|
||||
</span>
|
||||
</div>
|
||||
{alreadySubbed ? (
|
||||
<span className="text-2xs text-slate-400 italic">購読中</span>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
|
||||
disabled={subscribe.isPending}
|
||||
onClick={() =>
|
||||
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'search' })
|
||||
}
|
||||
>
|
||||
Subscribe (search)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
|
||||
disabled={subscribe.isPending}
|
||||
onClick={() =>
|
||||
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'inject' })
|
||||
}
|
||||
>
|
||||
Subscribe (inject)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{subscribe.isError && (
|
||||
<p className="mt-1 text-2xs text-red-600">{(subscribe.error as Error).message}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Inject Preview */}
|
||||
<section>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800 mb-1">Inject Preview</h3>
|
||||
<p className="text-2xs text-slate-500 mb-2">
|
||||
現在 inject モードで購読中のノートが LLM コンテキストに挿入されます。
|
||||
</p>
|
||||
{preview.isLoading && (
|
||||
<p className="text-[13px] text-slate-400">Loading…</p>
|
||||
)}
|
||||
{preview.isError && (
|
||||
<p className="text-[13px] text-red-500">Failed to load inject preview.</p>
|
||||
)}
|
||||
{!preview.isLoading && !preview.isError && (
|
||||
<>
|
||||
{(preview.data?.items ?? []).length === 0 ? (
|
||||
<p className="text-[13px] text-slate-400">inject 対象のノートはありません</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 mb-2">
|
||||
{(preview.data?.items ?? []).map((it) => (
|
||||
<li
|
||||
key={`${it.owner_id}/${it.folder}/${it.file_name}`}
|
||||
className="flex items-center gap-2 text-[13px] font-mono text-slate-700"
|
||||
>
|
||||
<span className="flex-1">{it.owner_id}/{it.folder}/{it.file_name}</span>
|
||||
<span className="text-slate-400 text-2xs">{it.size_kb} KB</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="text-2xs text-slate-500 pt-1 border-t border-hairline">
|
||||
Total:{' '}
|
||||
<span className="font-semibold text-slate-700">{preview.data?.total_kb ?? 0} KB</span>
|
||||
{' '}/{' '}
|
||||
<span className="font-semibold text-slate-700">{preview.data?.budget_kb ?? 0} KB</span>
|
||||
{' '}budget
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{openNote && (
|
||||
<NoteContentModal
|
||||
ownerId={openNote.ownerId}
|
||||
folder={openNote.folder}
|
||||
fileName={openNote.fileName}
|
||||
onClose={() => setOpenNote(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueries, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileTree, type SubdirId, type FileEntry, FILE_SUBDIRS } from './FileTree';
|
||||
import { MonacoFileEditor } from './MonacoFileEditor';
|
||||
import { SaveAsScriptDialog } from './SaveAsScriptDialog';
|
||||
import { ScriptDiffReview } from './ScriptDiffReview';
|
||||
import { BrowserSessionsPanel } from './BrowserSessionsPanel';
|
||||
import { McpPanel } from './McpPanel';
|
||||
import { AgentsMdPanel } from './AgentsMdPanel';
|
||||
import { NewFileForm } from './NewFileForm';
|
||||
import { PetsPanel } from './PetsPanel';
|
||||
import { SshConnectionsPanel } from './SshConnectionsPanel';
|
||||
import { NotesPanel } from './NotesPanel';
|
||||
import { SubscriptionsPanel } from './SubscriptionsPanel';
|
||||
import { SkillsPanel } from './SkillsPanel';
|
||||
/** All subdirs shown in the tree — both real file-based and virtual. */
|
||||
const ALL_SUBDIRS: SubdirId[] = ['agents-md', 'scripts', 'browser-macros', 'templates', 'recordings', 'notes', 'subscribed-notes', 'pets', 'browser-sessions', 'mcp', 'skills', 'ssh-connections', 'trash', 'memory'];
|
||||
|
||||
const SUBDIR_INFO: { id: SubdirId; icon: string; title: string; desc: string; agency: string }[] = [
|
||||
{
|
||||
id: 'agents-md',
|
||||
icon: '📖',
|
||||
title: 'AGENTS.md',
|
||||
desc: 'タスク起動時に system prompt へ自動注入される、ユーザー専用の永続的な指示。 「常に丁寧な日本語で答える」「Tailwind を優先」等、毎タスクで覚えて欲しい好み・ルールを書く。 最大 64KB。 ファイル形式は markdown。',
|
||||
agency: 'ユーザー編集 / エージェントが自動参照',
|
||||
},
|
||||
{
|
||||
id: 'scripts',
|
||||
icon: '📜',
|
||||
title: 'scripts/',
|
||||
desc: 'AI 生成の汎用 Node スクリプト。エージェントが RunUserScript ツールで実行 (kind: "script")。Chromium 起動なし、main({ params }) シグネチャ。データ整形・API 呼び出し・計算・ファイル変換等の繰り返し処理に向く。',
|
||||
agency: 'エージェント/ユーザー両方 / 軽量・高速',
|
||||
},
|
||||
{
|
||||
id: 'browser-macros',
|
||||
icon: '🤖',
|
||||
title: 'browser-macros/',
|
||||
desc: 'Playwright ベースのブラウザマクロ。recordings/ から "Save as Script" で生成、または UI で手書き。RunUserScript ツール (kind: "browser-macro") で実行。main({ context, params }) シグネチャで context は Playwright BrowserContext。session_profile_id で保存済みログインを利用可能。.next.js は self-healing 失敗時の自動パッチ候補で、Diff レビュー後に accept/reject。',
|
||||
agency: 'エージェント実行 / UI で recordings → スクリプト化',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: '📄',
|
||||
title: 'templates/',
|
||||
desc: '定型文・雛形の置き場。UI で作成・編集する。エージェントが ReadUserTemplate で本文を読むか、RenderUserTemplate で frontmatter.params の {{var}} を埋めた結果を取得できる。報告書の雛形・メール文面・コードボイラープレート等を貯めておくと、繰り返しタスクで「雛形を埋めて」と指示しやすい。',
|
||||
agency: 'ユーザー作成 / エージェントが ReadUserTemplate / RenderUserTemplate で利用',
|
||||
},
|
||||
{
|
||||
id: 'recordings',
|
||||
icon: '🎬',
|
||||
title: 'recordings/',
|
||||
desc: 'BrowseWeb 操作トレース (JSON)。BrowseWeb 呼び出しで recordTo パラメータを指定すると、成功したアクションがバッファされ、タスク終了時にここへ書き出される。"Save as Script" でブラウザマクロに変換できる (browser-macros/ へ保存)。',
|
||||
agency: 'エージェントが記録 / UI でスクリプト化',
|
||||
},
|
||||
{
|
||||
id: 'pets',
|
||||
icon: '◉',
|
||||
title: 'pets/',
|
||||
desc: 'Codex Pets 互換のキャラクターをユーザーごとに import する場所。Chat 画面右下に表示され、タスク状態やツール呼び出しに応じて小さく反応する。',
|
||||
agency: 'ユーザー管理 / Chat UI が参照',
|
||||
},
|
||||
{
|
||||
id: 'browser-sessions',
|
||||
icon: '🌐',
|
||||
title: 'browser-sessions/',
|
||||
desc: 'ブラウザのログインプロファイル管理。CAPTCHA や 2FA の壁を越えて取得した cookie/storage を user-scoped に暗号化保存し、browser-macros から session_profile_id で参照する。',
|
||||
agency: 'ユーザー管理 (noVNC でログイン → save) / browser-macros が利用',
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
icon: '🗑',
|
||||
title: 'trash/',
|
||||
desc: '削除ファイルの退避先。ハードデリートはせず、`{YYYYMMDD-HHMMSS}-{rand4hex}-{name}` 命名で着地する。script の accept/reject で旧版もここへ。閲覧は read-only、復元したい場合は内容コピーで元 subdir に PUT。30 日経過したファイルはサーバ起動時 / 24h 毎に自動削除 (`tools.trash_retention_days` で変更可)。',
|
||||
agency: 'ソフト削除 / read-only / 30 日自動 cleanup',
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
icon: '🧠',
|
||||
title: 'memory/',
|
||||
desc: 'エージェントの永続事実置き場。`MEMORY.md` (index) がタスク起動時に system prompt へ自動注入 (32 KB cap)。`{name}.md` は frontmatter (type ∈ user/feedback/project/reference) + 本文の構造。UpdateUserMemory / ReadUserMemory ツール経由でエージェントが管理。',
|
||||
agency: 'エージェント管理 / UI からは read-only',
|
||||
},
|
||||
{
|
||||
id: 'mcp',
|
||||
icon: '🔌',
|
||||
title: 'mcp/',
|
||||
desc: 'MCP サーバーの登録・接続管理・設定変更をまとめて行えます。OAuth / API key 認証、ツール一覧の取得、接続状態の確認がここで完結します。credentials は AES-256-GCM で暗号化して保存されます。',
|
||||
agency: 'ユーザー管理 / 管理者は global サーバーも追加可能',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
icon: '📚',
|
||||
title: 'skills/',
|
||||
desc: 'エージェントのスキル (参照知識・手順書) を管理します。URL からインストール、手動作成、編集、削除が可能。スキルはタスク実行時にエージェントへコンテキストとして注入されます。',
|
||||
agency: 'ユーザー管理 / エージェントが ReadSkill で参照',
|
||||
},
|
||||
{
|
||||
id: 'ssh-connections',
|
||||
icon: '🔐',
|
||||
title: 'ssh-connections/',
|
||||
desc: 'エージェントの SshExec / SshUpload / SshDownload ツールが利用する SSH 接続を管理します。秘密鍵は envelope encryption (AES-256-GCM + per-user DEK) で保存され、ホストキーは TOFU でユーザー確認後に固定されます。グローバル接続は管理者が登録し、ピースごとに grant を付与した時のみ参照可能です。',
|
||||
agency: 'ユーザー管理 / グローバル接続は管理者が登録',
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
icon: '📝',
|
||||
title: 'notes/',
|
||||
desc: '他のエージェントや他のユーザーと共有したい情報を Markdown で書く場所です。visibility を設定して公開範囲を制御できます。SearchNotes / ReadNote / WriteNote ツールでエージェントがアクセスできます。',
|
||||
agency: 'ユーザー作成 / エージェントが SearchNotes / ReadNote / WriteNote で利用',
|
||||
},
|
||||
{
|
||||
id: 'subscribed-notes',
|
||||
icon: '🔔',
|
||||
title: 'Subscribed Notes',
|
||||
desc: '他のユーザーが公開している notes フォルダーを購読・発見します。search モードは SearchNotes ツールで横断検索でき、inject モードは LLM コンテキストに自動注入します。',
|
||||
agency: 'ユーザー管理 / エージェントが自動参照 (inject モード)',
|
||||
},
|
||||
];
|
||||
|
||||
interface FolderListResponse {
|
||||
files: FileEntry[];
|
||||
}
|
||||
|
||||
async function apiFolderList(subdir: SubdirId): Promise<FileEntry[]> {
|
||||
const res = await fetch(`/api/users/me/folder/list?subdir=${subdir}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`List failed: ${res.status}`);
|
||||
const data: FolderListResponse = await res.json();
|
||||
return data.files ?? [];
|
||||
}
|
||||
|
||||
interface NoteDiscoverRow {
|
||||
folder: string;
|
||||
file_name: string;
|
||||
updated_at: number;
|
||||
content_size: number;
|
||||
}
|
||||
|
||||
/** Fetch all own notes via the discover API (unlimited depth, returns folder/file pairs). */
|
||||
async function apiNotesList(): Promise<FileEntry[]> {
|
||||
const res = await fetch('/api/notes/discover?owner_id=me&limit=200', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Notes list failed: ${res.status}`);
|
||||
const data: { rows: NoteDiscoverRow[] } = await res.json();
|
||||
return (data.rows ?? []).map((r) => ({
|
||||
// Use "folder/file.md" as the virtual file name so FileTree shows the full path
|
||||
name: `${r.folder}/${r.file_name}`,
|
||||
size: r.content_size,
|
||||
mtime: new Date(r.updated_at).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function apiFolderGet(subdir: SubdirId, path: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function apiFolderPut(subdir: SubdirId, path: string, body: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`Save failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function apiFolderDelete(subdir: SubdirId, path: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
|
||||
{ method: 'DELETE', credentials: 'include' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/** Virtual subdirs don't have real files on disk */
|
||||
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
|
||||
|
||||
/** Subdirs where users can create new files from the UI */
|
||||
const WRITABLE_USER_SUBDIRS = new Set<SubdirId>(['scripts', 'browser-macros', 'templates']);
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
interface UserFolderTabProps {
|
||||
showToast?: ShowToast;
|
||||
}
|
||||
|
||||
export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
|
||||
const [selectedSubdir, setSelectedSubdir] = useState<SubdirId | null>('scripts');
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [editorDirty, setEditorDirty] = useState(false);
|
||||
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Fetch the current user (needed for SubscriptionsPanel)
|
||||
const meQuery = useQuery<{ id: string }>({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const currentUserId = meQuery.data?.id ?? '';
|
||||
|
||||
// Only file-based subdirs are fetched; notes uses a separate discover endpoint
|
||||
// because notes live at depth 2 (notes/<folder>/<file>.md) and folder/list only shows depth 1.
|
||||
const fileSubdirs = FILE_SUBDIRS.filter((s) => s !== 'notes');
|
||||
const subdirResults = useQueries({
|
||||
queries: fileSubdirs.map(subdir => ({
|
||||
queryKey: ['userfolder', 'list', subdir],
|
||||
queryFn: () => apiFolderList(subdir),
|
||||
staleTime: 10_000,
|
||||
})),
|
||||
});
|
||||
|
||||
// Separate query for notes that uses the discover API instead of the folder-list API
|
||||
const notesListQuery = useQuery<FileEntry[]>({
|
||||
queryKey: ['userfolder', 'list', 'notes'],
|
||||
queryFn: () => apiNotesList(),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const subdirFilesMap: Partial<Record<SubdirId, { subdir: SubdirId; files: FileEntry[]; loading: boolean }>> = Object.fromEntries(
|
||||
fileSubdirs.map((subdir, i) => [
|
||||
subdir,
|
||||
{
|
||||
subdir,
|
||||
files: subdirResults[i]!.data ?? [],
|
||||
loading: subdirResults[i]!.isLoading,
|
||||
},
|
||||
])
|
||||
);
|
||||
// Inject notes separately using the discover-based listing (depth-2 aware)
|
||||
subdirFilesMap['notes'] = {
|
||||
subdir: 'notes',
|
||||
files: notesListQuery.data ?? [],
|
||||
loading: notesListQuery.isLoading,
|
||||
};
|
||||
|
||||
// Build the tree data: real subdirs get files, virtual ones get empty placeholders
|
||||
const SUBDIRS = ALL_SUBDIRS;
|
||||
const subdirQueries = SUBDIRS.map(subdir => {
|
||||
if (VIRTUAL_SUBDIRS.has(subdir)) {
|
||||
return { subdir, files: [], loading: false };
|
||||
}
|
||||
return subdirFilesMap[subdir] ?? { subdir, files: [], loading: false };
|
||||
});
|
||||
|
||||
// File content query — only when a file is selected (and not virtual subdir)
|
||||
const fileQuery = useQuery<string>({
|
||||
queryKey: ['userfolder', 'file', selectedSubdir, selectedFile],
|
||||
queryFn: () => apiFolderGet(selectedSubdir!, selectedFile!),
|
||||
enabled: !!(selectedSubdir && selectedFile && !VIRTUAL_SUBDIRS.has(selectedSubdir)),
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ subdir, file }: { subdir: SubdirId; file: string }) =>
|
||||
apiFolderDelete(subdir, file),
|
||||
onSuccess: (_data, { subdir, file }) => {
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', subdir] });
|
||||
if (selectedSubdir === subdir && selectedFile === file) {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
},
|
||||
onError: (err, { subdir, file }) => {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
const label = `${subdir}/${file} の削除に失敗`;
|
||||
if (showToast) showToast(`${label}: ${msg}`, 'error');
|
||||
else console.error(`${label}: ${msg}`);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedSubdirData = subdirQueries.find(q => q.subdir === selectedSubdir);
|
||||
const selectedFileMeta = selectedSubdirData?.files.find(f => f.name === selectedFile);
|
||||
|
||||
const handleSave = async (content: string) => {
|
||||
if (!selectedSubdir || !selectedFile) return;
|
||||
await apiFolderPut(selectedSubdir, selectedFile, content);
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
|
||||
qc.setQueryData(
|
||||
['userfolder', 'file', selectedSubdir, selectedFile],
|
||||
content,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (subdir: SubdirId, file: string) => {
|
||||
if (!window.confirm(`Delete ${subdir}/${file}?`)) return;
|
||||
deleteMutation.mutate({ subdir, file });
|
||||
};
|
||||
|
||||
function handleSelectSubdir(subdir: SubdirId) {
|
||||
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
|
||||
if (selectedSubdir === subdir) {
|
||||
setSelectedSubdir(null);
|
||||
setSelectedFile(null);
|
||||
} else {
|
||||
setSelectedSubdir(subdir);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectFile(subdir: SubdirId, file: string) {
|
||||
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
|
||||
setSelectedSubdir(subdir);
|
||||
setSelectedFile(file);
|
||||
}
|
||||
|
||||
// Determine right-pane content
|
||||
const isVirtualSelected = selectedSubdir !== null && VIRTUAL_SUBDIRS.has(selectedSubdir);
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-2 p-2 overflow-hidden">
|
||||
{/* Left: file tree */}
|
||||
<div
|
||||
className="bg-white border border-hairline rounded-md overflow-hidden flex flex-col"
|
||||
style={{ width: 'clamp(200px, 22vw, 280px)', flexShrink: 0 }}
|
||||
>
|
||||
<div className="flex-shrink-0 px-3 py-2.5 border-b border-hairline">
|
||||
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
User Folder
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<FileTree
|
||||
subdirData={subdirQueries}
|
||||
selectedSubdir={selectedSubdir}
|
||||
selectedFile={selectedFile}
|
||||
onSelectSubdir={handleSelectSubdir}
|
||||
onSelectFile={handleSelectFile}
|
||||
onDeleteFile={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: editor / virtual panel */}
|
||||
<div className="flex-1 min-w-0 bg-white border border-hairline rounded-md overflow-hidden flex flex-col">
|
||||
{/* agents-md virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'agents-md' && (
|
||||
<AgentsMdPanel onDirtyChange={setEditorDirty} />
|
||||
)}
|
||||
|
||||
{/* browser-sessions virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'browser-sessions' && (
|
||||
<BrowserSessionsPanel />
|
||||
)}
|
||||
|
||||
{/* mcp virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'mcp' && (
|
||||
<McpPanel showToast={showToast} />
|
||||
)}
|
||||
|
||||
{/* skills virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'skills' && (
|
||||
<SkillsPanel />
|
||||
)}
|
||||
|
||||
{/* pets virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'pets' && (
|
||||
<PetsPanel showToast={showToast} />
|
||||
)}
|
||||
|
||||
{/* ssh-connections virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'ssh-connections' && (
|
||||
<SshConnectionsPanel showToast={showToast} />
|
||||
)}
|
||||
|
||||
{/* subscribed-notes virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'subscribed-notes' && (
|
||||
<SubscriptionsPanel currentUserId={currentUserId} />
|
||||
)}
|
||||
|
||||
{/* notes/ pane — uses discover API for listing (depth 2) + NotesPanel editor */}
|
||||
{selectedSubdir === 'notes' && (
|
||||
<NotesPanel
|
||||
filePath={selectedFile}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
|
||||
}}
|
||||
onSelectFile={(path) => {
|
||||
setSelectedFile(path);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* File-based content (non-notes subdirs) */}
|
||||
{!isVirtualSelected && selectedSubdir !== 'notes' && (
|
||||
<>
|
||||
{/* Save as Script toolbar — shown only in recordings/ when a .json file is selected */}
|
||||
{selectedSubdir === 'recordings' && selectedFile?.endsWith('.json') && (
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border-b border-hairline bg-surface-2/50">
|
||||
<span className="text-2xs text-slate-500 flex-1">
|
||||
Recording: <span className="font-mono">{selectedFile}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSaveAsDialogOpen(true)}
|
||||
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
|
||||
>
|
||||
Save as Script
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{selectedSubdir && selectedFile ? (
|
||||
/* If a .next.js patch file is selected in browser-macros/, show the diff review pane */
|
||||
selectedSubdir === 'browser-macros' && selectedFile.endsWith('.next.js') ? (
|
||||
<ScriptDiffReview
|
||||
scriptName={selectedFile.slice(0, -'.next.js'.length)}
|
||||
showToast={showToast}
|
||||
onClose={(acceptedScript) => {
|
||||
if (acceptedScript) {
|
||||
setSelectedFile(acceptedScript);
|
||||
} else {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : fileQuery.isLoading ? (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
|
||||
Loading…
|
||||
</div>
|
||||
) : fileQuery.isError ? (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
|
||||
Failed to load file.
|
||||
</div>
|
||||
) : (
|
||||
<MonacoFileEditor
|
||||
subdir={selectedSubdir}
|
||||
filename={selectedFile}
|
||||
content={fileQuery.data ?? ''}
|
||||
mtime={selectedFileMeta?.mtime ?? ''}
|
||||
size={selectedFileMeta?.size ?? 0}
|
||||
onSave={handleSave}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
{selectedSubdir && WRITABLE_USER_SUBDIRS.has(selectedSubdir) ? (
|
||||
/* Focused view for a selected writable subdir: info + new-file form */
|
||||
(() => {
|
||||
const info = SUBDIR_INFO.find(i => i.id === selectedSubdir);
|
||||
if (!info) return null;
|
||||
const files = selectedSubdirData?.files ?? [];
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex gap-3">
|
||||
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>
|
||||
{info.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-semibold text-slate-900">{info.title}</h2>
|
||||
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">{info.desc}</p>
|
||||
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{info.agency}</p>
|
||||
</div>
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="mb-4 text-xs text-slate-500">
|
||||
現在 {files.length} 件のファイルがあります。左のツリーから選択して編集できます。
|
||||
</div>
|
||||
)}
|
||||
<NewFileForm
|
||||
subdir={selectedSubdir as 'scripts' | 'browser-macros' | 'templates'}
|
||||
existingFilenames={files.map(f => f.name)}
|
||||
onCreate={async (filename, skeleton) => {
|
||||
await apiFolderPut(selectedSubdir, filename, skeleton);
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
|
||||
setSelectedFile(filename);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
/* Full overview when no subdir is selected (or non-writable subdir selected without a file) */
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">User Folder</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
セッションをまたいで永続化される、ユーザーごとの作業空間です。
|
||||
左のツリーから subdirectory を開き、ファイルを選択して編集できます。
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-5">
|
||||
{SUBDIR_INFO.map(({ id, icon, title, desc, agency }) => (
|
||||
<li key={id} className="flex gap-3">
|
||||
<span className="text-xl leading-none mt-0.5 select-none" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-semibold text-slate-900">{title}</div>
|
||||
<p className="text-[13px] text-slate-600 mt-1 leading-relaxed">{desc}</p>
|
||||
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{agency}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save as Script dialog — navigates to browser-macros on success */}
|
||||
{saveAsDialogOpen && selectedFile?.endsWith('.json') && (
|
||||
<SaveAsScriptDialog
|
||||
recordingName={selectedFile.endsWith('.json') ? selectedFile.slice(0, -5) : selectedFile}
|
||||
onClose={() => setSaveAsDialogOpen(false)}
|
||||
onSuccess={(scriptName) => {
|
||||
setSaveAsDialogOpen(false);
|
||||
// Navigate to the new macro in browser-macros/
|
||||
setSelectedSubdir('browser-macros');
|
||||
setSelectedFile(scriptName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user