feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
|
||||
const ask = config.ask ?? {};
|
||||
const subtasks = config.subtasks ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Ask / Subtasks</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Ask: Max Per Job</FieldLabel>
|
||||
<FieldInput type="number" value={ask.maxPerJob ?? ''} onChange={v => onChange('ask.maxPerJob', v ? Number(v) : undefined)} />
|
||||
<HelpText>1 Job あたりの ASK(ユーザーへの質問)上限</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Subtasks: Max Depth</FieldLabel>
|
||||
<FieldInput type="number" value={subtasks.maxDepth ?? ''} onChange={v => onChange('subtasks.maxDepth', v ? Number(v) : undefined)} />
|
||||
<HelpText>サブタスクのネスト最大深度</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
type AssetKind = 'logo' | 'favicon';
|
||||
|
||||
const ACCEPT: Record<AssetKind, string> = {
|
||||
logo: '.svg,.png,.jpg,.jpeg,.webp,.gif',
|
||||
favicon: '.svg,.png,.ico,.webp',
|
||||
};
|
||||
|
||||
const MAX_SIZE: Record<AssetKind, number> = {
|
||||
logo: 2 * 1024 * 1024,
|
||||
favicon: 256 * 1024,
|
||||
};
|
||||
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
const buf = await file.arrayBuffer();
|
||||
// btoa does not accept non-ASCII; convert via chunked construction.
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function AssetUploader({
|
||||
kind,
|
||||
currentUrl,
|
||||
onChanged,
|
||||
}: {
|
||||
kind: AssetKind;
|
||||
currentUrl: string | null;
|
||||
/** Called after a successful upload/delete with the new URL (null when cleared). */
|
||||
onChanged: (newUrl: string | null) => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePick = () => fileRef.current?.click();
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setError(null);
|
||||
if (file.size > MAX_SIZE[kind]) {
|
||||
setError(`ファイルサイズが上限 ${Math.round(MAX_SIZE[kind] / 1024)}KB を超えています`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setBusy(true);
|
||||
const contentBase64 = await fileToBase64(file);
|
||||
const res = await fetch('/api/branding/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind, filename: file.name, contentBase64 }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error ?? `アップロードに失敗しました (${res.status})`);
|
||||
}
|
||||
onChanged(typeof body.url === 'string' ? body.url : null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/branding/upload?kind=${kind}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`削除に失敗しました (${res.status})`);
|
||||
onChanged(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-12 w-12 flex-shrink-0 rounded-md border border-hairline bg-surface flex items-center justify-center overflow-hidden ${
|
||||
kind === 'favicon' ? 'bg-white' : ''
|
||||
}`}
|
||||
>
|
||||
{currentUrl ? (
|
||||
<img src={currentUrl} alt="" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">未設定</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={ACCEPT[kind]}
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePick}
|
||||
disabled={busy}
|
||||
className="px-2.5 h-7 text-2xs font-medium bg-white border border-hairline rounded-md text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{currentUrl ? '差し替え' : 'アップロード'}
|
||||
</button>
|
||||
{currentUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClear()}
|
||||
disabled={busy}
|
||||
className="px-2.5 h-7 text-2xs font-medium text-red-700 border border-red-200 bg-white hover:bg-red-50 rounded-md disabled:opacity-50 transition-colors"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-1 truncate font-mono">
|
||||
{currentUrl ?? `${ACCEPT[kind]} / 最大 ${Math.round(MAX_SIZE[kind] / 1024)}KB`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="mt-1.5 text-2xs text-red-600">⚠ {error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandingForm({ config, onChange }: SectionFormProps) {
|
||||
const branding = config.branding ?? {};
|
||||
const primaryColor = branding.primaryColor ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
// サーバーは upload 時に config.yaml を直接書き換える。
|
||||
// ローカル draft も同期して、他フィールドの編集中でも整合を保つ。
|
||||
const handleAssetChange = (field: 'logoUrl' | 'faviconUrl') => (newUrl: string | null) => {
|
||||
onChange(`branding.${field}`, newUrl ?? '');
|
||||
// Branding API (TopBar の画像など) は別クエリなので個別に再取得
|
||||
void qc.invalidateQueries({ queryKey: ['branding'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Branding</h2>
|
||||
<p className="text-xs text-slate-500 -mt-3">
|
||||
UI のタイトル・配色・ロゴ・フッターをカスタマイズします。設定は <code>config.yaml</code> の <code>branding</code> セクション、
|
||||
画像は <code>data/branding/</code> に保存されます。どちらも <code>.gitignore</code> 済みで
|
||||
<code> git pull</code> の影響を受けません。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<FieldLabel>アプリ名</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.appName ?? ''}
|
||||
onChange={v => onChange('branding.appName', v)}
|
||||
placeholder="MAESTRO"
|
||||
/>
|
||||
<HelpText>TopBar 左上と ブラウザタイトルに表示されます。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>プライマリカラー</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={primaryColor || '#2563eb'}
|
||||
onChange={e => onChange('branding.primaryColor', e.target.value)}
|
||||
className="h-9 w-12 rounded border border-slate-300 p-0 cursor-pointer"
|
||||
aria-label="プライマリカラー"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={primaryColor}
|
||||
onChange={e => onChange('branding.primaryColor', e.target.value)}
|
||||
placeholder="#2563eb"
|
||||
className="flex-1 px-3 py-2 text-sm font-mono border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<HelpText>ヘッダーのアプリ名など、ブランドカラーに反映されます(hex / rgb)。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ログイン画面の見出し</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.loginPageTitle ?? ''}
|
||||
onChange={v => onChange('branding.loginPageTitle', v)}
|
||||
placeholder="MAESTRO"
|
||||
/>
|
||||
<HelpText>未設定の場合はアプリ名を使用します。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ロゴ</FieldLabel>
|
||||
<AssetUploader
|
||||
kind="logo"
|
||||
currentUrl={branding.logoUrl || null}
|
||||
onChanged={handleAssetChange('logoUrl')}
|
||||
/>
|
||||
<HelpText>TopBar 左上に表示されます。未設定時はデフォルトのアイコンを使用します。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Favicon</FieldLabel>
|
||||
<AssetUploader
|
||||
kind="favicon"
|
||||
currentUrl={branding.faviconUrl || null}
|
||||
onChanged={handleAssetChange('faviconUrl')}
|
||||
/>
|
||||
<HelpText>ブラウザタブに表示されます。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>フッター文言</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.footerText ?? ''}
|
||||
onChange={v => onChange('branding.footerText', v)}
|
||||
placeholder="© 2026 Your Team"
|
||||
/>
|
||||
<HelpText>画面最下部に小さく表示されます。未設定時は非表示。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
const browser = config.browser ?? {};
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Browser</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Page Timeout (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.browserPageTimeout ?? 60000}
|
||||
onChange={v => onChange('tools.browserPageTimeout', Number(v))} />
|
||||
<HelpText>ページ読み込みのタイムアウト(ミリ秒)。デフォルト: 60000</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Action Timeout (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.browserActionTimeout ?? 30000}
|
||||
onChange={v => onChange('tools.browserActionTimeout', Number(v))} />
|
||||
<HelpText>ブラウザ操作のタイムアウト(ミリ秒)。デフォルト: 30000</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Browser Channel</FieldLabel>
|
||||
<select value={browser.channel ?? 'chromium'}
|
||||
onChange={e => onChange('browser.channel', e.target.value)}
|
||||
className="w-full h-9 px-2 text-[13px] border border-hairline rounded-md">
|
||||
<option value="chromium">chromium (bundled, default)</option>
|
||||
<option value="chrome">chrome (system Google Chrome)</option>
|
||||
<option value="msedge">msedge (system Microsoft Edge)</option>
|
||||
</select>
|
||||
<HelpText>
|
||||
Google ログイン等で「セキュアでないブラウザ」と弾かれる場合は <code>chrome</code> に切替。
|
||||
ホストに <code>google-chrome</code> がインストールされている必要あり。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Executable Path (optional)</FieldLabel>
|
||||
<FieldInput value={browser.executablePath ?? ''}
|
||||
onChange={v => onChange('browser.executablePath', v || undefined)} />
|
||||
<HelpText>非標準パスにあるブラウザを使う場合のみ指定。未設定なら channel に従う。</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>VNC Base Port</FieldLabel>
|
||||
<FieldInput type="number" value={browser.vncBasePort ?? 5900}
|
||||
onChange={v => onChange('browser.vncBasePort', Number(v))} />
|
||||
<HelpText>VNC サーバーのベースポート。デフォルト: 5900</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Session Data Directory</FieldLabel>
|
||||
<FieldInput value={browser.sessionDataDir ?? './data/browser-sessions'}
|
||||
onChange={v => onChange('browser.sessionDataDir', v)} />
|
||||
<HelpText>Cookie を永続化するディレクトリ。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Sessions</FieldLabel>
|
||||
<FieldInput type="number" value={browser.maxSessions ?? 3}
|
||||
onChange={v => onChange('browser.maxSessions', Number(v))} />
|
||||
<HelpText>同時に起動できるセッションの最大数。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useConfig } from '../../hooks/useConfig';
|
||||
import { useUnsavedGuard } from '../../lib/unsavedGuard';
|
||||
import { updateConfig } from '../../api';
|
||||
import { LlmWorkersForm } from './LlmWorkersForm';
|
||||
import { WorkspaceForm } from './WorkspaceForm';
|
||||
import { PathsStorageForm } from './PathsStorageForm';
|
||||
import { ExecutionForm } from './ExecutionForm';
|
||||
import { ToolsForm } from './ToolsForm';
|
||||
import { ToolsWebForm } from './ToolsWebForm';
|
||||
import { ToolsMediaForm } from './ToolsMediaForm';
|
||||
import { ToolsExternalForm } from './ToolsExternalForm';
|
||||
import { KnowledgeNamespacesForm } from './KnowledgeNamespacesForm';
|
||||
import { AskSubtasksForm } from './AskSubtasksForm';
|
||||
import { SearchFilterForm } from './SearchFilterForm';
|
||||
import { BrowserSettingsForm } from './BrowserSettingsForm';
|
||||
import { ContextForm } from './ContextForm';
|
||||
import { SafetyForm } from './SafetyForm';
|
||||
import { PreferencesForm } from './PreferencesForm';
|
||||
import { NotificationsForm } from './NotificationsForm';
|
||||
import { BrandingForm } from './BrandingForm';
|
||||
import { MemoryLearningForm } from './MemoryLearningForm';
|
||||
import { MetricsForm } from './MetricsForm';
|
||||
import { ReflectionForm } from './ReflectionForm';
|
||||
import { McpForm } from './McpForm';
|
||||
import { SshForm } from './SshForm';
|
||||
import { GatewayServerForm } from './GatewayServerForm';
|
||||
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
interface ConfigFormProps {
|
||||
section: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
function PreferencesFormWrapper() {
|
||||
const auth = useAuthState();
|
||||
if (auth.mode !== 'authenticated') {
|
||||
return <div className="text-sm text-slate-500">Log in to manage preferences.</div>;
|
||||
}
|
||||
return (
|
||||
<PreferencesForm
|
||||
user={{
|
||||
defaultVisibility: auth.user.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: auth.user.defaultVisibilityOrgId ?? null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Set a value at a dot-separated path in an object (immutable). */
|
||||
function setNestedValue(obj: any, path: string, value: any): any {
|
||||
const keys = path.split('.');
|
||||
if (keys.length === 1) {
|
||||
return { ...obj, [keys[0]]: value };
|
||||
}
|
||||
const [first, ...rest] = keys;
|
||||
return { ...obj, [first]: setNestedValue(obj[first] ?? {}, rest.join('.'), value) };
|
||||
}
|
||||
|
||||
/** Count leaf-level differences between two values. Arrays are compared as a single leaf. */
|
||||
function countDiff(a: any, b: any): number {
|
||||
if (a === b) return 0;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return JSON.stringify(a) === JSON.stringify(b) ? 0 : 1;
|
||||
}
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
let total = 0;
|
||||
for (const k of keys) total += countDiff(a[k], b[k]);
|
||||
return total;
|
||||
}
|
||||
// Treat undefined/null/empty-string as equivalent to reduce noise from optional fields.
|
||||
const norm = (v: any) => (v === undefined || v === null || v === '' ? null : v);
|
||||
return norm(a) === norm(b) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
// User-scoped sections use their own per-user APIs and should not load the
|
||||
// admin /api/config draft. Render them stand-alone without the global save bar.
|
||||
if (section === 'preferences') {
|
||||
return <div className="max-w-2xl"><PreferencesFormWrapper /></div>;
|
||||
}
|
||||
if (section === 'notifications') {
|
||||
return <div className="max-w-2xl"><NotificationsForm /></div>;
|
||||
}
|
||||
if (section === 'memory-learning') {
|
||||
return <div className="max-w-2xl"><MemoryLearningForm /></div>;
|
||||
}
|
||||
if (!isAdmin) {
|
||||
return <div className="max-w-2xl text-sm text-slate-500">この設定は管理者のみ閲覧できます。</div>;
|
||||
}
|
||||
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
|
||||
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
|
||||
// dedicated branch here. The keys UI lives inside GatewayServerForm
|
||||
// as the Virtual Keys section.
|
||||
return <ConfigFormInner section={section} isAdmin={isAdmin} />;
|
||||
}
|
||||
|
||||
function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
const { data, isLoading, error, refetch } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [draft, setDraft] = useState<any>(null);
|
||||
const [etag, setEtag] = useState('');
|
||||
const [overriddenByEnv, setOverriddenByEnv] = useState<Record<string, boolean>>({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// Sync fetched config into draft
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
setEtag(data.etag);
|
||||
setOverriddenByEnv(data.overriddenByEnv);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleChange = useCallback((path: string, value: any) => {
|
||||
setDraft((prev: any) => setNestedValue(prev, path, value));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
setIsDirty(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draft) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateConfig(draft, etag);
|
||||
if (result.conflict) {
|
||||
if (confirm('設定が他で変更されました。再読み込みしますか?')) {
|
||||
await refetch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
setToast('保存しました');
|
||||
setTimeout(() => setToast(null), 2000);
|
||||
} catch (e: any) {
|
||||
setToast(`エラー: ${e.message}`);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0;
|
||||
// Arm beforeunload + register an in-app guard so navigating elsewhere
|
||||
// (e.g. clicking a TopBar tab) prompts when there are unsaved fields.
|
||||
useUnsavedGuard(dirtyCount > 0);
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-400">Loading...</div>;
|
||||
if (error) return <div className="text-sm text-red-500">設定の読み込みに失敗しました</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
const formProps = { config: draft, onChange: handleChange, overriddenByEnv };
|
||||
|
||||
const sectionForm = (() => {
|
||||
switch (section) {
|
||||
// ── System
|
||||
case 'branding': return <BrandingForm {...formProps} />;
|
||||
case 'paths-storage': return <PathsStorageForm {...formProps} />;
|
||||
case 'execution': return <ExecutionForm {...formProps} />;
|
||||
|
||||
// ── LLM (Step 7: LlmWorkersForm replaces ProviderForm; reads llm.workers,
|
||||
// not provider.workers. 'provider' alias kept for URL backwards compat.)
|
||||
case 'provider':
|
||||
case 'llm-workers':
|
||||
return <LlmWorkersForm {...formProps} />;
|
||||
case 'gateway-server': return <GatewayServerForm {...formProps} />;
|
||||
case 'llm-metrics': return <MetricsForm {...formProps} />;
|
||||
|
||||
// ── Agent Runtime
|
||||
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
|
||||
case 'context': return <ContextForm {...formProps} />;
|
||||
case 'safety': return <SafetyForm {...formProps} />;
|
||||
case 'reflection': return <ReflectionForm {...formProps} />;
|
||||
|
||||
// ── Tools sub-sections — Step 9 split the legacy grab-bag
|
||||
// ToolsForm into focused per-category forms. Each binds to the
|
||||
// same `tools.*` config keys as before (functionally equivalent),
|
||||
// just without the in-form sub-tab nav.
|
||||
case 'tools-web':
|
||||
// Folds SearchFilterForm in as a sub-section (Step 3
|
||||
// INVESTIGATE #3 follow-up).
|
||||
return <ToolsWebForm {...formProps} />;
|
||||
case 'tools-browser':
|
||||
// Browser runtime (page/action timeouts, channel, etc.) is its
|
||||
// own form — kept verbatim, just relocated.
|
||||
return <BrowserSettingsForm {...formProps} />;
|
||||
case 'tools-media':
|
||||
return <ToolsMediaForm {...formProps} />;
|
||||
case 'tools-external':
|
||||
return <ToolsExternalForm {...formProps} />;
|
||||
case 'tools-legacy-knowledge':
|
||||
return <KnowledgeNamespacesForm {...formProps} />;
|
||||
|
||||
// ── MCP & Connections
|
||||
case 'mcp': return <McpForm {...formProps} />;
|
||||
|
||||
// ── SSH (admin)
|
||||
case 'ssh': return <SshForm {...formProps} showToast={(msg) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}} />;
|
||||
|
||||
// ── Legacy ids — kept here only so a direct URL hit still renders
|
||||
// something during the transition window. The Settings page also
|
||||
// rewrites the URL to the new id via `LEGACY_SECTION_REDIRECT`, so
|
||||
// these branches are mostly defensive. ('provider' moved to the
|
||||
// LLM-Workers case above — Step 7 — so it now lands on the new
|
||||
// form. 'tools' bookmark still resolves to the legacy ToolsForm
|
||||
// with all sub-tabs visible per Step 9 fallback design.)
|
||||
case 'workspace': return <WorkspaceForm {...formProps} />;
|
||||
case 'tools': return <ToolsForm {...formProps} />;
|
||||
case 'search-filter': return <SearchFilterForm {...formProps} />;
|
||||
case 'browser-settings': return <BrowserSettingsForm {...formProps} />;
|
||||
|
||||
default: return <div className="text-sm text-slate-400">Unknown section: {section}</div>;
|
||||
}
|
||||
})();
|
||||
|
||||
const dirty = dirtyCount > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl pb-20">
|
||||
{sectionForm}
|
||||
|
||||
{/* Sticky save bar: stays visible while scrolling, gets a strong amber
|
||||
accent when dirty so it cannot be missed. The pb-20 on the parent
|
||||
reserves space so the bar never overlaps the last form field. */}
|
||||
<div
|
||||
className={`sticky bottom-0 px-3 py-2.5 mt-6 border rounded-md flex items-center justify-end gap-2 transition-colors ${
|
||||
dirty
|
||||
? 'bg-amber-50 border-amber-300 shadow-[0_2px_8px_rgba(180,83,9,0.08)]'
|
||||
: 'bg-white border-hairline'
|
||||
}`}
|
||||
>
|
||||
{toast ? (
|
||||
<span className={`text-2xs mr-auto ${toast.startsWith('エラー') ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
) : dirty ? (
|
||||
<span className="text-xs mr-auto text-amber-800 flex items-center gap-1.5 font-medium min-w-0">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
|
||||
<span className="truncate">
|
||||
<span className="hidden sm:inline">未保存: {dirtyCount} 項目 — 「Save & Apply」を押すまで反映されません</span>
|
||||
<span className="sm:hidden">未保存 {dirtyCount}</span>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!dirty}
|
||||
className="px-3 h-8 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
<span className="hidden sm:inline">Discard Changes</span>
|
||||
<span className="sm:hidden">Discard</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{saving ? 'Saving...' : (
|
||||
<>
|
||||
<span className="hidden sm:inline">Save & Apply</span>
|
||||
<span className="sm:hidden">Save</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function ContextForm({ config, onChange }: SectionFormProps) {
|
||||
const ctx = config.context ?? {};
|
||||
const thresholds = ctx.thresholds ?? [
|
||||
{ ratio: 0.7, action: 'warn' },
|
||||
{ ratio: 0.85, action: 'prompt' },
|
||||
{ ratio: 0.95, action: 'force_transition' },
|
||||
];
|
||||
|
||||
const updateThreshold = (index: number, field: string, value: string | number) => {
|
||||
const updated = thresholds.map((t: { ratio: number; action: string }, i: number) =>
|
||||
i === index ? { ...t, [field]: field === 'ratio' ? Number(value) : value } : t
|
||||
);
|
||||
onChange('context.thresholds', updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Context</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Limit Tokens</FieldLabel>
|
||||
<FieldInput type="number" value={ctx.limitTokens ?? ''}
|
||||
onChange={v => onChange('context.limitTokens', v ? Number(v) : undefined)}
|
||||
placeholder="auto (Ollama API から取得)" />
|
||||
<HelpText>トークン上限の手動指定。空欄で自動取得。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Thresholds (閾値)</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{thresholds.map((t: { ratio: number; action: string }, i: number) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input type="number" step="0.01" min="0" max="1"
|
||||
value={t.ratio}
|
||||
onChange={e => updateThreshold(i, 'ratio', e.target.value)}
|
||||
className="w-20 px-2 py-1 text-sm border border-slate-300 rounded" />
|
||||
<select value={t.action}
|
||||
onChange={e => updateThreshold(i, 'action', e.target.value)}
|
||||
className="px-2 py-1 text-sm border border-slate-300 rounded">
|
||||
<option value="warn">warn</option>
|
||||
<option value="prompt">prompt</option>
|
||||
<option value="force_transition">force_transition</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>
|
||||
コンテキスト使用率に応じたアクション。ratio は 0〜1。
|
||||
warn: ログに警告を出力するのみ / prompt: LLM へ遷移を促すメッセージを注入 / force_transition: default_next に強制遷移
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Execution — concurrency, max_movements, and job retry settings.
|
||||
*
|
||||
* Step 3 carve-out from the old "Workspace" form. The path/storage half
|
||||
* of Workspace lives in PathsStorageForm. Field paths are unchanged so
|
||||
* the underlying config keys keep working without a migration.
|
||||
*/
|
||||
export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Execution</h2>
|
||||
<HelpText>同時実行数、1 ジョブあたりの movement 上限、ジョブ失敗時のリトライ設定。</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Concurrency</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={config.concurrency ?? ''}
|
||||
onChange={v => onChange('concurrency', v ? Number(v) : undefined)}
|
||||
disabled={!!overriddenByEnv['concurrency']}
|
||||
disabledReason="CONCURRENCY 環境変数で上書き中"
|
||||
/>
|
||||
{overriddenByEnv['concurrency'] && <EnvOverrideWarning />}
|
||||
<HelpText>同時実行可能なジョブ数</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Movements</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={config.maxMovements ?? ''}
|
||||
onChange={v => onChange('maxMovements', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>1ジョブあたりの最大 movement 数</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Retry</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Attempts</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={config.retry?.maxAttempts ?? 3}
|
||||
onChange={v => onChange('retry.maxAttempts', Number(v))}
|
||||
/>
|
||||
<HelpText>ジョブ失敗時の最大リトライ回数。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Backoff Seconds</FieldLabel>
|
||||
<FieldInput
|
||||
value={(config.retry?.backoffSeconds ?? [60, 300, 900]).join(', ')}
|
||||
onChange={v =>
|
||||
onChange(
|
||||
'retry.backoffSeconds',
|
||||
v.split(',').map((s: string) => Number(s.trim())).filter((n: number) => !isNaN(n)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HelpText>リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CreateInput {
|
||||
team: string;
|
||||
allowedModels?: string[];
|
||||
tokensBudget?: number | null;
|
||||
rateLimitRpm?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onSubmit: (input: CreateInput) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for issuing a new gateway virtual key. The team field is
|
||||
* required; allowed_models / tokens_budget / rate_limit_rpm are
|
||||
* optional and empty = "no limit".
|
||||
*
|
||||
* After submit succeeds the parent shows the GatewayKeyRawKeyDialog
|
||||
* with the raw bearer; this dialog never displays it.
|
||||
*/
|
||||
export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }: Props) {
|
||||
const [team, setTeam] = useState('');
|
||||
const [allowedModelsText, setAllowedModelsText] = useState('');
|
||||
const [tokensBudgetText, setTokensBudgetText] = useState('');
|
||||
const [rateLimitRpmText, setRateLimitRpmText] = useState('');
|
||||
|
||||
function buildPayload(): CreateInput | { error: string } {
|
||||
const t = team.trim();
|
||||
if (!/^[a-zA-Z0-9._-]{1,64}$/.test(t)) {
|
||||
return { error: 'team must match /^[a-zA-Z0-9._-]{1,64}$/' };
|
||||
}
|
||||
const allowedModels = allowedModelsText
|
||||
.split(/[\n,]/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
const tokensBudget = tokensBudgetText.trim() === '' ? null : Number(tokensBudgetText);
|
||||
const rateLimitRpm = rateLimitRpmText.trim() === '' ? null : Number(rateLimitRpmText);
|
||||
if (tokensBudget !== null && (!Number.isFinite(tokensBudget) || tokensBudget <= 0)) {
|
||||
return { error: 'tokens budget must be a positive integer' };
|
||||
}
|
||||
if (rateLimitRpm !== null && (!Number.isFinite(rateLimitRpm) || rateLimitRpm <= 0)) {
|
||||
return { error: 'rate limit (rpm) must be a positive integer' };
|
||||
}
|
||||
return {
|
||||
team: t,
|
||||
allowedModels: allowedModels.length > 0 ? allowedModels : undefined,
|
||||
tokensBudget,
|
||||
rateLimitRpm,
|
||||
};
|
||||
}
|
||||
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLocalError(null);
|
||||
const payload = buildPayload();
|
||||
if ('error' in payload) {
|
||||
setLocalError(payload.error);
|
||||
return;
|
||||
}
|
||||
await onSubmit(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">新規 Gateway Key 発行</h3>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
team <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={team}
|
||||
onChange={(e) => setTeam(e.target.value)}
|
||||
placeholder="alpha"
|
||||
autoFocus
|
||||
required
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded mb-3"
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Allowed models (1 行 / カンマ区切り、空欄=制限なし)
|
||||
</label>
|
||||
<textarea
|
||||
value={allowedModelsText}
|
||||
onChange={(e) => setAllowedModelsText(e.target.value)}
|
||||
placeholder="qwen3:8b qwen3:14b"
|
||||
rows={2}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded mb-3 font-mono"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Tokens budget / month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={tokensBudgetText}
|
||||
onChange={(e) => setTokensBudgetText(e.target.value)}
|
||||
placeholder="無制限"
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Rate limit (rpm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={rateLimitRpmText}
|
||||
onChange={(e) => setRateLimitRpmText(e.target.value)}
|
||||
placeholder="無制限"
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(localError || error) && (
|
||||
<div className="text-sm text-red-600 mb-3">{localError ?? error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '発行中...' : '発行する'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
rawKey: string;
|
||||
team: string;
|
||||
reason: 'created' | 'rotated';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time raw bearer reveal. The DB never stores the raw value — once
|
||||
* this dialog closes the operator can never see it again, so we:
|
||||
* - require an explicit "I've saved it" acknowledgement before close
|
||||
* - show a copy-to-clipboard button as the obvious primary action
|
||||
* - warn loudly in red
|
||||
* - trap ESC, browser back, and tab-close (beforeunload) until
|
||||
* acknowledged so a stray keypress can't lose the key (F10)
|
||||
*
|
||||
* The dialog is intentionally modal (overlay + focus trap via tabindex).
|
||||
*/
|
||||
export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
|
||||
// F10: while the raw key is on-screen and not acknowledged, block the
|
||||
// common dismissal paths that would otherwise silently lose it:
|
||||
// - ESC keypress (Escape closes most modals by convention)
|
||||
// - browser back / forward (popstate)
|
||||
// - tab close / refresh (beforeunload — best-effort browser warning)
|
||||
// We intentionally do NOT block the dialog's own Close button (gated
|
||||
// by the `acknowledged` checkbox) or the overlay click (which the
|
||||
// current design already ignores).
|
||||
useEffect(() => {
|
||||
if (acknowledged) return;
|
||||
|
||||
const onKeydown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeydown, { capture: true });
|
||||
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent): string => {
|
||||
e.preventDefault();
|
||||
const msg = 'Gateway API key has not been saved. Closing this page will lose it forever.';
|
||||
// Modern browsers ignore the returned string but require it set
|
||||
// for the warning dialog to appear. Setting both for cross-browser
|
||||
// safety (Chrome reads returnValue, some older Firefox reads return).
|
||||
(e as BeforeUnloadEvent & { returnValue: string }).returnValue = msg;
|
||||
return msg;
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
|
||||
// Push a sentinel history entry so the next back-button press lands
|
||||
// here (where we re-push it). Best-effort: doesn't fully prevent
|
||||
// navigation in every browser, but turns a single back-tap into a
|
||||
// visible alert + re-block.
|
||||
let pushed = false;
|
||||
try {
|
||||
window.history.pushState({ aaoGatewayKeyTrap: true }, '', window.location.href);
|
||||
pushed = true;
|
||||
} catch { /* SSR / sandboxed iframes: skip */ }
|
||||
const onPopState = (e: PopStateEvent): void => {
|
||||
e.preventDefault?.();
|
||||
try {
|
||||
window.history.pushState({ aaoGatewayKeyTrap: true }, '', window.location.href);
|
||||
} catch { /* ignore */ }
|
||||
alert(
|
||||
'API key has not been saved. Copy it and tick "保存しました" before navigating away.',
|
||||
);
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeydown, { capture: true });
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
// Drop the sentinel we pushed so the user's history isn't littered.
|
||||
if (pushed) {
|
||||
try {
|
||||
if (window.history.state && (window.history.state as { aaoGatewayKeyTrap?: boolean }).aaoGatewayKeyTrap) {
|
||||
window.history.back();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
}, [acknowledged]);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Some browsers / contexts block clipboard access. The textarea
|
||||
// is selectable as a fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-1">
|
||||
{reason === 'created' ? '新しい Gateway Key を発行しました' : 'Gateway Key をローテーションしました'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">team: {team}</p>
|
||||
|
||||
<div className="rounded border border-red-300 bg-red-50 p-3 mb-3">
|
||||
<p className="text-sm text-red-800 font-medium">⚠️ このキーは今後二度と表示されません</p>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
必ずパスワードマネージャや LLM クライアントの設定にコピー・保存してから閉じてください。
|
||||
紛失した場合は Rotate で再発行する必要があります。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">Bearer Key (sk-aao-…)</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={rawKey}
|
||||
rows={2}
|
||||
className="w-full font-mono text-xs px-2 py-1.5 border border-slate-300 rounded bg-slate-50 select-all"
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
|
||||
>
|
||||
{copied ? '✓ Copied' : 'Copy to clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline mt-4 pt-4">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-slate-700">
|
||||
キーを安全に保存しました。今後このキーは表示できなくなることを理解しています。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!acknowledged}
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded border border-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getGatewayKeyUsage } from '../../api';
|
||||
|
||||
interface Props {
|
||||
keyId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-key usage detail. Shows current-month stats (with a progress bar
|
||||
* vs budget) and a simple bar chart of the last 6-12 months of token
|
||||
* usage. No external chart library — pure CSS bars keep the UI bundle
|
||||
* lean.
|
||||
*/
|
||||
export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['gateway-key-usage', keyId],
|
||||
queryFn: () => getGatewayKeyUsage(keyId),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const maxHistTokens = data
|
||||
? Math.max(1, ...data.history.map(h => h.tokensIn + h.tokensOut))
|
||||
: 1;
|
||||
|
||||
const pctUsed =
|
||||
data && data.tokensBudget !== null && data.tokensBudget > 0
|
||||
? Math.min(100, (data.tokensTotal / data.tokensBudget) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">Key 使用状況</h3>
|
||||
<p className="text-xs text-slate-500 font-mono">{keyId}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-700 text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-sm text-slate-500">Loading…</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600">取得エラー: {String((error as Error).message ?? error)}</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Current period summary */}
|
||||
<div className="border border-hairline rounded p-3 mb-4">
|
||||
<div className="flex justify-between items-baseline mb-2">
|
||||
<span className="text-xs font-medium text-slate-600 uppercase tracking-wide">
|
||||
今月 ({data.currentPeriod})
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Requests: {data.requestsThisMonth.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">Input tokens</div>
|
||||
<div className="font-mono">{fmtTokens(data.tokensIn)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">Output tokens</div>
|
||||
<div className="font-mono">{fmtTokens(data.tokensOut)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">Total / Budget</div>
|
||||
<div className="font-mono">
|
||||
{fmtTokens(data.tokensTotal)}{' '}
|
||||
<span className="text-slate-400">
|
||||
/ {data.tokensBudget !== null ? fmtTokens(data.tokensBudget) : '∞'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pctUsed !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 rounded bg-slate-100 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${pctUsed >= 100 ? 'bg-red-500' : pctUsed >= 80 ? 'bg-amber-500' : 'bg-accent'}`}
|
||||
style={{ width: `${pctUsed}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 text-right">
|
||||
{pctUsed.toFixed(1)}% used
|
||||
{data.remaining !== null && ` · ${fmtTokens(data.remaining)} remaining`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.rateLimitRpm !== null && (
|
||||
<div className="text-xs text-slate-500 mt-2">
|
||||
Rate limit: {data.rateLimitRpm} rpm
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History bars */}
|
||||
<div className="border border-hairline rounded p-3">
|
||||
<div className="text-xs font-medium text-slate-600 uppercase tracking-wide mb-2">
|
||||
過去 12 か月
|
||||
</div>
|
||||
{data.history.length === 0 ? (
|
||||
<div className="text-sm text-slate-400 italic">履歴なし</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{data.history.map((h) => {
|
||||
const total = h.tokensIn + h.tokensOut;
|
||||
const widthPct = (total / maxHistTokens) * 100;
|
||||
return (
|
||||
<div key={h.period} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono w-16 text-slate-500">{h.period}</span>
|
||||
<div className="flex-1 h-3 bg-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent/70"
|
||||
style={{ width: `${Math.max(2, widthPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono w-20 text-right text-slate-600">
|
||||
{fmtTokens(total)}
|
||||
</span>
|
||||
<span className="font-mono w-12 text-right text-slate-400">
|
||||
{h.requests.toLocaleString()} rq
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { GatewayKey } from '../../api';
|
||||
import {
|
||||
listGatewayKeys,
|
||||
createGatewayKey,
|
||||
revokeGatewayKey,
|
||||
rotateGatewayKey,
|
||||
patchGatewayKey,
|
||||
} from '../../api';
|
||||
import { GatewayKeyCreateDialog } from './GatewayKeyCreateDialog';
|
||||
import { GatewayKeyRawKeyDialog } from './GatewayKeyRawKeyDialog';
|
||||
import { GatewayKeyUsagePanel } from './GatewayKeyUsagePanel';
|
||||
|
||||
interface Props {
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → LLM → Gateway Server → Virtual Keys section (Step 8).
|
||||
*
|
||||
* Renders the Gateway Keys list + create/rotate/revoke actions inline
|
||||
* within the Gateway Server form. Previously this lived under its own
|
||||
* sidebar entry (`gateway-keys`); the entry was removed in Step 8 because
|
||||
* key management is a Gateway Server operation, not a separate concern.
|
||||
*
|
||||
* List table + actions per row (Detail / Rotate / Revoke). Create
|
||||
* dialog issues a fresh sk-aao-* key; the raw value is then surfaced
|
||||
* in a one-time reveal dialog with copy + acknowledge gate.
|
||||
*
|
||||
* Filters: ?team= (text input) and ?activeOnly= (checkbox). Both
|
||||
* roundtrip through React Query for cache scoping.
|
||||
*
|
||||
* Note: this section talks to its own admin REST API (not the global
|
||||
* config save flow), so edits here are applied immediately and do not
|
||||
* participate in the surrounding form's draft/dirty/Save&Apply bar.
|
||||
*/
|
||||
export function GatewayKeysSection({ showToast }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [createSubmitting, setCreateSubmitting] = useState(false);
|
||||
const [rawDialog, setRawDialog] = useState<{ rawKey: string; team: string; reason: 'created' | 'rotated' } | null>(null);
|
||||
const [usagePanelId, setUsagePanelId] = useState<string | null>(null);
|
||||
const [budgetDraft, setBudgetDraft] = useState<{ id: string; value: string } | null>(null);
|
||||
const [rpmDraft, setRpmDraft] = useState<{ id: string; value: string } | null>(null);
|
||||
|
||||
const queryKey = ['gateway-keys', { team: teamFilter || undefined, activeOnly }];
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => listGatewayKeys({ team: teamFilter || undefined, activeOnly }),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
function notify(msg: string, variant: 'success' | 'error' = 'success'): void {
|
||||
if (showToast) showToast(msg, variant);
|
||||
}
|
||||
|
||||
async function handleCreate(input: {
|
||||
team: string;
|
||||
allowedModels?: string[];
|
||||
tokensBudget?: number | null;
|
||||
rateLimitRpm?: number | null;
|
||||
}): Promise<void> {
|
||||
setCreateSubmitting(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const created = await createGatewayKey(input);
|
||||
setCreating(false);
|
||||
if (created.key) {
|
||||
setRawDialog({ rawKey: created.key, team: created.team, reason: 'created' });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Gateway key を発行しました');
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCreateSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotate(row: GatewayKey): Promise<void> {
|
||||
if (!confirm(`team=${row.team} のキーをローテーションしますか?\n旧キーは無効になります。`)) return;
|
||||
try {
|
||||
const created = await rotateGatewayKey(row.id);
|
||||
if (created.key) {
|
||||
setRawDialog({ rawKey: created.key, team: created.team, reason: 'rotated' });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Rotate しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(row: GatewayKey): Promise<void> {
|
||||
if (!confirm(`team=${row.team} のキー (${row.keyPrefix}…) を Revoke しますか?\nこの操作は取り消せません。`)) return;
|
||||
try {
|
||||
await revokeGatewayKey(row.id);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Revoke しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePatch(id: string, patch: { tokensBudget?: number | null; rateLimitRpm?: number | null }): Promise<void> {
|
||||
try {
|
||||
await patchGatewayKey(id, patch);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('更新しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function commitBudget(id: string): void {
|
||||
if (!budgetDraft || budgetDraft.id !== id) return;
|
||||
const v = budgetDraft.value.trim();
|
||||
const parsed = v === '' ? null : Number(v);
|
||||
if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) {
|
||||
notify('tokens budget must be a positive integer or empty', 'error');
|
||||
setBudgetDraft(null);
|
||||
return;
|
||||
}
|
||||
handlePatch(id, { tokensBudget: parsed });
|
||||
setBudgetDraft(null);
|
||||
}
|
||||
|
||||
function commitRpm(id: string): void {
|
||||
if (!rpmDraft || rpmDraft.id !== id) return;
|
||||
const v = rpmDraft.value.trim();
|
||||
const parsed = v === '' ? null : Number(v);
|
||||
if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) {
|
||||
notify('rate limit must be a positive integer or empty', 'error');
|
||||
setRpmDraft(null);
|
||||
return;
|
||||
}
|
||||
handlePatch(id, { rateLimitRpm: parsed });
|
||||
setRpmDraft(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">Team filter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={teamFilter}
|
||||
onChange={(e) => setTeamFilter(e.target.value)}
|
||||
placeholder="alpha"
|
||||
className="px-2 py-1 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm mt-5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeOnly}
|
||||
onChange={(e) => setActiveOnly(e.target.checked)}
|
||||
/>
|
||||
Active only
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="ml-auto px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreateError(null); setCreating(true); }}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
|
||||
>
|
||||
+ 新規発行
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-hairline rounded overflow-hidden">
|
||||
{isLoading && <div className="p-3 text-sm text-slate-500">Loading…</div>}
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600">
|
||||
取得エラー: {String((error as Error).message ?? error)}
|
||||
</div>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<div className="p-6 text-center text-sm text-slate-400">
|
||||
キーが登録されていません。「+ 新規発行」から作成できます。
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium">Prefix</th>
|
||||
<th className="text-left p-2 font-medium">Team</th>
|
||||
<th className="text-left p-2 font-medium">Models</th>
|
||||
<th className="text-right p-2 font-medium">Budget</th>
|
||||
<th className="text-right p-2 font-medium">Rpm</th>
|
||||
<th className="text-left p-2 font-medium">Source</th>
|
||||
<th className="text-left p-2 font-medium">Status</th>
|
||||
<th className="text-right p-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => {
|
||||
const isRevoked = row.revokedAt !== null;
|
||||
const isConfig = row.source === 'config-import';
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`border-t border-hairline ${isRevoked ? 'bg-slate-50 text-slate-400' : ''}`}
|
||||
>
|
||||
<td className="p-2 font-mono text-xs">{row.keyPrefix}…</td>
|
||||
<td className="p-2">{row.team}</td>
|
||||
<td className="p-2 text-xs text-slate-500">
|
||||
{row.allowedModels === null
|
||||
? <span className="text-slate-400 italic">all</span>
|
||||
: row.allowedModels.join(', ')}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-xs">
|
||||
{budgetDraft?.id === row.id ? (
|
||||
<input
|
||||
type="number"
|
||||
autoFocus
|
||||
value={budgetDraft.value}
|
||||
onChange={(e) => setBudgetDraft({ id: row.id, value: e.target.value })}
|
||||
onBlur={() => commitBudget(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitBudget(row.id);
|
||||
if (e.key === 'Escape') setBudgetDraft(null);
|
||||
}}
|
||||
className="w-20 px-1 py-0.5 text-xs border border-slate-300 rounded text-right"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked || isConfig}
|
||||
onClick={() => setBudgetDraft({ id: row.id, value: row.tokensBudget?.toString() ?? '' })}
|
||||
className="text-left disabled:cursor-not-allowed hover:underline"
|
||||
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.tokensBudget !== null ? row.tokensBudget.toLocaleString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-xs">
|
||||
{rpmDraft?.id === row.id ? (
|
||||
<input
|
||||
type="number"
|
||||
autoFocus
|
||||
value={rpmDraft.value}
|
||||
onChange={(e) => setRpmDraft({ id: row.id, value: e.target.value })}
|
||||
onBlur={() => commitRpm(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitRpm(row.id);
|
||||
if (e.key === 'Escape') setRpmDraft(null);
|
||||
}}
|
||||
className="w-16 px-1 py-0.5 text-xs border border-slate-300 rounded text-right"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked || isConfig}
|
||||
onClick={() => setRpmDraft({ id: row.id, value: row.rateLimitRpm?.toString() ?? '' })}
|
||||
className="text-left disabled:cursor-not-allowed hover:underline"
|
||||
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.rateLimitRpm !== null ? row.rateLimitRpm.toString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
{isConfig ? (
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-600">config</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 bg-accent-soft rounded text-accent">admin</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
{isRevoked ? (
|
||||
<span className="px-1.5 py-0.5 bg-red-50 text-red-700 rounded">revoked</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 bg-green-50 text-green-700 rounded">active</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUsagePanelId(row.id)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
詳細
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked}
|
||||
onClick={() => handleRotate(row)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked}
|
||||
onClick={() => handleRevoke(row)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
Tokens budget は月次 UTC でリセット。Rate limit (rpm) は 60 秒スライディングウィンドウ。
|
||||
config-import のキー(config.yaml から取り込まれたもの)は値の編集ができません。
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<GatewayKeyCreateDialog
|
||||
onCancel={() => setCreating(false)}
|
||||
onSubmit={handleCreate}
|
||||
submitting={createSubmitting}
|
||||
error={createError}
|
||||
/>
|
||||
)}
|
||||
{rawDialog && (
|
||||
<GatewayKeyRawKeyDialog
|
||||
rawKey={rawDialog.rawKey}
|
||||
team={rawDialog.team}
|
||||
reason={rawDialog.reason}
|
||||
onClose={() => setRawDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{usagePanelId && (
|
||||
<GatewayKeyUsagePanel
|
||||
keyId={usagePanelId}
|
||||
onClose={() => setUsagePanelId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
import { getGatewayServerStatus, type GatewayServerStatus } from '../../api';
|
||||
import { GatewayKeysSection } from './GatewayKeysSection';
|
||||
|
||||
/**
|
||||
* Settings → LLM → Gateway Server.
|
||||
*
|
||||
* Sections (top → bottom):
|
||||
* - Enable toggle + live status badge
|
||||
* - Listen port
|
||||
* - Backends list (config-driven, draft/Save&Apply)
|
||||
* - Virtual Keys (key management — admin REST API, applied immediately)
|
||||
* - Advanced timeouts (request / upstream / shutdown)
|
||||
*
|
||||
* Step 8 of the 2026-05-21 settings restructure folded the standalone
|
||||
* Gateway Keys sidebar entry into this form as the "Virtual Keys"
|
||||
* section, so key issuance / rotation / revocation lives next to the
|
||||
* Gateway it configures. The keys section uses its own admin REST API
|
||||
* and therefore bypasses the surrounding Save & Apply bar — that's why
|
||||
* it's allowed to share this form even though it doesn't touch
|
||||
* `config.gateway.*`.
|
||||
*
|
||||
* Status badge polls /api/admin/gateway/status every 3s so an enable
|
||||
* flip is reflected near-instantly without a page reload.
|
||||
*
|
||||
* Field names are camelCase to match the in-memory AppConfig shape
|
||||
* (src/config.ts:transformKeys converts YAML snake_case → camelCase on
|
||||
* load, and toSnakeKeys reverses on save). The displayed labels keep the
|
||||
* YAML names (max_slots, api_key, ...) so operators can map back to
|
||||
* config.yaml.example without translation.
|
||||
*/
|
||||
interface GatewayBackend {
|
||||
id?: string;
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
maxSlots?: number;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
interface GatewayConfigShape {
|
||||
enabled?: boolean;
|
||||
listenPort?: number;
|
||||
requestTimeoutSec?: number;
|
||||
upstreamTimeoutSec?: number;
|
||||
shutdownGracefulSec?: number;
|
||||
backends?: GatewayBackend[];
|
||||
virtualKeys?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render value for a `<FieldInput type="number">`. Returns the number
|
||||
* when it's a finite integer-typed value, otherwise `fallback`. Without
|
||||
* this, `value={NaN ?? 1}` resolves to `NaN` (nullish-coalesce only
|
||||
* traps null/undefined), and React renders the literal string "NaN"
|
||||
* into the input — see https://gitea.example.com/.../issues for the
|
||||
* Phase 3c regression that motivated this helper.
|
||||
*/
|
||||
function numberValue(n: unknown, fallback: number | ''): number | '' {
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the string emitted by a number `<FieldInput>` into either a
|
||||
* finite number, or `undefined` for empty / unparseable input. Storing
|
||||
* `undefined` (rather than NaN) keeps the next render's value clean.
|
||||
*/
|
||||
function parseNumberInput(v: string): number | undefined {
|
||||
if (v === '') return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: GatewayServerStatus | undefined }) {
|
||||
if (!status) {
|
||||
return <span className="text-2xs text-slate-400">…</span>;
|
||||
}
|
||||
if (status.state === 'unavailable') {
|
||||
return (
|
||||
<span title={status.message} className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">
|
||||
unavailable
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.state === 'running') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
running (mounted at /v1, port {status.sharedPort})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.state === 'misconfigured') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200">
|
||||
misconfigured ({status.errors.length} error{status.errors.length === 1 ? '' : 's'})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.state === 'starting' || status.state === 'stopping') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
{status.state}…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">
|
||||
disabled
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backend rows in-form so the operator sees red-bordered fields
|
||||
* before they hit Save. Returns a per-row error map keyed by row index.
|
||||
*/
|
||||
function validateBackends(backends: GatewayBackend[]): Map<number, string[]> {
|
||||
const errors = new Map<number, string[]>();
|
||||
const seenIds = new Set<string>();
|
||||
backends.forEach((b, i) => {
|
||||
const rowErrs: string[] = [];
|
||||
if (!b.id || b.id.trim() === '') rowErrs.push('id required');
|
||||
else if (seenIds.has(b.id)) rowErrs.push('duplicate id');
|
||||
if (b.id) seenIds.add(b.id);
|
||||
if (!b.endpoint || b.endpoint.trim() === '') rowErrs.push('endpoint required');
|
||||
else {
|
||||
try {
|
||||
const u = new URL(b.endpoint);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
rowErrs.push('endpoint must be http(s)');
|
||||
}
|
||||
} catch {
|
||||
rowErrs.push('endpoint invalid URL');
|
||||
}
|
||||
}
|
||||
if (!b.model || b.model.trim() === '') rowErrs.push('model required');
|
||||
if (
|
||||
typeof b.maxSlots !== 'number'
|
||||
|| !Number.isFinite(b.maxSlots)
|
||||
|| b.maxSlots <= 0
|
||||
|| !Number.isInteger(b.maxSlots)
|
||||
) {
|
||||
rowErrs.push('max_slots must be positive integer');
|
||||
}
|
||||
if (rowErrs.length > 0) errors.set(i, rowErrs);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
const gw: GatewayConfigShape = config.gateway ?? {};
|
||||
const backends: GatewayBackend[] = Array.isArray(gw.backends) ? gw.backends : [];
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['gateway-server-status'],
|
||||
queryFn: getGatewayServerStatus,
|
||||
refetchInterval: 3000,
|
||||
staleTime: 1000,
|
||||
});
|
||||
|
||||
const backendErrors = useMemo(() => validateBackends(backends), [backends]);
|
||||
|
||||
const setEnabled = (v: boolean) => onChange('gateway.enabled', v);
|
||||
const setListenPort = (v: number | undefined) => onChange('gateway.listenPort', v);
|
||||
const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v);
|
||||
const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v);
|
||||
const setShutdownGraceful = (v: number | undefined) => onChange('gateway.shutdownGracefulSec', v);
|
||||
|
||||
const updateBackend = (i: number, field: keyof GatewayBackend, value: unknown) => {
|
||||
const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b));
|
||||
onChange('gateway.backends', next);
|
||||
};
|
||||
const addBackend = () => {
|
||||
const next: GatewayBackend = {
|
||||
id: `backend-${backends.length + 1}`,
|
||||
endpoint: '',
|
||||
model: '',
|
||||
maxSlots: 1,
|
||||
};
|
||||
onChange('gateway.backends', [...backends, next]);
|
||||
};
|
||||
const removeBackend = (i: number) => {
|
||||
onChange('gateway.backends', backends.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">Gateway Server</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
AAO 自身を LLM Gateway として動かす。有効にすると <code>/v1/chat/completions</code> などのエンドポイントが、worker UI と <strong>同じポート</strong>で待ち受けます (別 process 起動は不要)。他 AAO の <code>provider.workers[].endpoint</code> にこの URL を指定して GPU プールを共有できます。
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gw.enabled === true}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-medium text-slate-700">Enable Gateway</span>
|
||||
</label>
|
||||
<StatusBadge status={statusQuery.data} />
|
||||
</div>
|
||||
{statusQuery.data?.errors && statusQuery.data.errors.length > 0 && (
|
||||
<ul className="mt-2 text-xs text-red-700 bg-red-50 border border-red-200 rounded p-2 space-y-0.5">
|
||||
{statusQuery.data.errors.map((e, i) => (
|
||||
<li key={i}>• {e}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<FieldLabel>Listen port</FieldLabel>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.listenPort, 4000)}
|
||||
onChange={v => setListenPort(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
<strong>同 process 時はこの値は使われません</strong>: worker UI と同じポート (
|
||||
{statusQuery.data?.sharedPort ?? '9876'}) を共有します。<code>AAO_MODE=gateway</code> で別 process 起動した場合のみ有効。
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 pt-1.5">
|
||||
別 process deploy:{' '}
|
||||
<code className="text-2xs">AAO_MODE=gateway scripts/gateway.sh start</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<h3 className="text-sm font-medium text-slate-700">Backends</h3>
|
||||
<button
|
||||
onClick={addBackend}
|
||||
className="px-2.5 h-7 text-xs text-accent border border-accent rounded-md hover:bg-accent-soft"
|
||||
>
|
||||
+ Add backend
|
||||
</button>
|
||||
</div>
|
||||
<HelpText>
|
||||
ルーティング先の llama-server / Ollama / vLLM など。Gateway は <code>request.model</code> に一致する <code>model</code> を持つ最も busy ではない backend に割り振ります。<br/>
|
||||
<strong>api_key の保存形式</strong>: フォームで入力した値は <code>config.yaml</code> に平文で保存されます。<code>${'${VAR}'}</code> 形式の env var 参照はフォーム保存時に literal 文字列として保存されるため、env 経由で渡したい場合は <code>config.yaml</code> を直接編集してください。
|
||||
</HelpText>
|
||||
{backends.length === 0 ? (
|
||||
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 mt-2 text-center">
|
||||
backend が未登録です。最低 1 つ追加してください。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mt-2">
|
||||
{backends.map((b, i) => {
|
||||
const errs = backendErrors.get(i) ?? [];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border rounded-md p-3 space-y-2 relative ${errs.length > 0 ? 'border-red-200 bg-red-50/30' : 'border-slate-200'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => removeBackend(i)}
|
||||
className="absolute top-1.5 right-2 text-slate-400 hover:text-red-500 text-lg leading-none"
|
||||
title="この backend を削除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<FieldLabel>id</FieldLabel>
|
||||
<FieldInput value={b.id ?? ''} onChange={v => updateBackend(i, 'id', v)} placeholder="gpu-rtx-a" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>model</FieldLabel>
|
||||
<FieldInput value={b.model ?? ''} onChange={v => updateBackend(i, 'model', v)} placeholder="qwen3:8b" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>endpoint</FieldLabel>
|
||||
<FieldInput value={b.endpoint ?? ''} onChange={v => updateBackend(i, 'endpoint', v)} placeholder="http://gpu-host:8080/v1" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>max_slots</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(b.maxSlots, 1)}
|
||||
onChange={v => updateBackend(i, 'maxSlots', parseNumberInput(v))}
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>api_key (任意)</FieldLabel>
|
||||
<FieldInput
|
||||
type="password"
|
||||
value={b.apiKey ?? ''}
|
||||
onChange={v => updateBackend(i, 'apiKey', v || undefined)}
|
||||
placeholder="sk-... or ${ENV_VAR}"
|
||||
/>
|
||||
{/* G2: warn when the operator saves a literal
|
||||
${VAR} reference. The config writer stores
|
||||
fields verbatim — env substitution happens at
|
||||
load time, so saving the form turns the
|
||||
reference into a literal "${VAR}" string and
|
||||
the env var indirection is lost. */}
|
||||
{typeof b.apiKey === 'string' && b.apiKey.trimStart().startsWith('${') && (
|
||||
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
||||
env var reference detected: 保存すると <code>{b.apiKey}</code> がそのまま config.yaml に書き込まれ、起動時の env 置換は効かなくなります。env 経由で渡すなら config.yaml を直接編集してください。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{errs.length > 0 && (
|
||||
<ul className="text-2xs text-red-600 list-disc pl-4 space-y-0.5">
|
||||
{errs.map((e, ei) => <li key={ei}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<div className="mb-1.5">
|
||||
<h3 className="text-sm font-medium text-slate-700">Virtual Keys</h3>
|
||||
</div>
|
||||
<HelpText>
|
||||
この Gateway を経由してアクセスするための <code>sk-aao-*</code> bearer key を発行・rotate・revoke します。<br/>
|
||||
<strong>注意</strong>: ここでの操作は Gateway Server の Save & Apply とは独立した admin API で即時反映されます (Save ボタンを押す必要はありません)。
|
||||
</HelpText>
|
||||
<div className="mt-2">
|
||||
<GatewayKeysSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="border-t border-hairline pt-3 group">
|
||||
<summary className="text-sm font-medium text-slate-700 cursor-pointer">
|
||||
Advanced
|
||||
</summary>
|
||||
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||
<div>
|
||||
<FieldLabel>request_timeout_sec</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.requestTimeoutSec, 600)}
|
||||
onChange={v => setRequestTimeout(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>chat 全体の budget (streaming 含む)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>upstream_timeout_sec</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.upstreamTimeoutSec, 30)}
|
||||
onChange={v => setUpstreamTimeout(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>1 chunk あたりの idle 上限</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>shutdown_graceful_sec</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.shutdownGracefulSec, 30)}
|
||||
onChange={v => setShutdownGraceful(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>SIGTERM 後の drain 上限</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">
|
||||
<p>
|
||||
<strong>Hot reload:</strong> ここでの変更は Save 直後に同 process gateway に反映されます (backend / virtual_key 変更は bounce が発生し、in-flight ストリームは graceful drain されます)。
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function HelpText({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs text-slate-400 mt-1">{children}</p>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { NamespaceEditor } from './NamespaceEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Legacy Knowledge (DKS) namespace settings.
|
||||
*
|
||||
* Replaces the `knowledge` tab of the legacy grab-bag `ToolsForm`.
|
||||
* The config keys are unchanged:
|
||||
*
|
||||
* tools.knowledge_service_url
|
||||
* tools.knowledge_namespaces
|
||||
*
|
||||
* Marked as legacy in PR #357; new knowledge integrations should go
|
||||
* through MCP servers. Existing namespaces remain editable / removable,
|
||||
* but adding new namespaces is disabled in the editor.
|
||||
*/
|
||||
export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps) {
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-800">Knowledge (DKS)</h2>
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-100 text-amber-800 border border-amber-300"
|
||||
title="この機能は legacy です。新規の知識検索統合は MCP server 経由を推奨"
|
||||
>
|
||||
LEGACY
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="note"
|
||||
className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900"
|
||||
>
|
||||
DKS 機能は <strong>legacy</strong> 化されており、新規の知識検索統合は{' '}
|
||||
<strong>MCP server 経由</strong> を推奨します。既存の namespace 設定は引き続き動作しますが、
|
||||
新規 namespace の追加はできません。{' '}
|
||||
<a
|
||||
href="/help"
|
||||
className="underline text-amber-900 hover:text-amber-700"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MCP 連携ガイドを開く
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Knowledge Service URL</FieldLabel>
|
||||
<FieldInput value={tools.knowledgeServiceUrl ?? ''} onChange={v => onChange('tools.knowledgeServiceUrl', v)}
|
||||
placeholder="http://dks-server:8100" />
|
||||
<HelpText>Document Knowledge Server (DKS) の API エンドポイント。未設定時は knowledge ツール無効。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Knowledge Namespaces</FieldLabel>
|
||||
<NamespaceEditor
|
||||
value={tools.knowledgeNamespaces ?? {}}
|
||||
onChange={v => onChange('tools.knowledgeNamespaces', v)}
|
||||
addDisabled
|
||||
addDisabledReason="新規 namespace 追加は MCP 経由を推奨"
|
||||
addDisabledHref="/help"
|
||||
/>
|
||||
<HelpText>DKS の名前空間と API キーの組み合わせ。既存項目の編集・削除は可能ですが、新規追加は無効化されています。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useMemo } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import { SecretInput } from './SecretInput';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Worker entry shape used by the v2 `llm.workers[]` config block. The
|
||||
* field names mirror what the server expects after camelCase
|
||||
* conversion (see src/config.ts:transformKeys). The runtime AppConfig
|
||||
* still uses `provider.workers` internally during the v1→v2 compat
|
||||
* window, but the API surface and this UI are v2-only.
|
||||
*/
|
||||
interface LlmWorker {
|
||||
id?: string;
|
||||
connectionType?: 'direct' | 'aao_gateway';
|
||||
endpoint?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
roles?: string[];
|
||||
maxConcurrency?: number;
|
||||
enabled?: boolean;
|
||||
vlm?: boolean;
|
||||
/**
|
||||
* Phase 1 compat: older `provider.workers[].proxy: true` rows are
|
||||
* mapped to `connectionType: aao_gateway` by the normalizer. We
|
||||
* still surface the field name so the UI can read legacy drafts
|
||||
* that haven't been migrated yet.
|
||||
*/
|
||||
proxy?: boolean;
|
||||
}
|
||||
|
||||
interface LlmConfigShape {
|
||||
timeoutMinutes?: number;
|
||||
retry?: {
|
||||
maxAttempts?: number;
|
||||
backoffMs?: number[];
|
||||
retryableStatus?: number[];
|
||||
};
|
||||
workers?: LlmWorker[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether an `aao_gateway` worker's endpoint appears to point at
|
||||
* the current AAO instance itself. Heuristic only — reverse proxies
|
||||
* and deployment-specific hostnames can defeat this, so we never block
|
||||
* save; the warning is purely an "are you sure?" hint.
|
||||
*
|
||||
* Triggers when the endpoint host is:
|
||||
* - `localhost` / `127.0.0.1` / `::1`
|
||||
* - the same host as `window.location.host` (excluding port mismatch
|
||||
* — a separate gateway process on the same box is legitimate)
|
||||
*
|
||||
* Phase 2 (out of scope for this PR) will replace this with a hard
|
||||
* UUID check against `/aao/instance-id`.
|
||||
*/
|
||||
function detectSelfLoop(endpoint: string | undefined): boolean {
|
||||
if (!endpoint) return false;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
|
||||
// Match against the browser's current hostname — same host, regardless
|
||||
// of port. This catches `http://my-aao.example/v1` when the operator
|
||||
// is editing settings on `my-aao.example` itself.
|
||||
if (typeof window !== 'undefined' && window.location?.hostname) {
|
||||
return host === window.location.hostname.toLowerCase();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → LLM → Workers.
|
||||
*
|
||||
* This is the v2 replacement for the old `ProviderForm` + inline
|
||||
* `WorkersBlock` pair. The big differences from the v1 forms:
|
||||
*
|
||||
* - reads/writes `llm.workers[]` instead of `provider.workers[]`
|
||||
* (the v1 form rendered empty after the API switched to v2 shape)
|
||||
* - each row carries `connectionType: direct | aao_gateway` instead
|
||||
* of a `proxy: true` toggle, so the rendered help text and warnings
|
||||
* can be specific to the connection style
|
||||
* - api keys use the 4-state `SecretInput` editor instead of a raw
|
||||
* `<input type="password">`, so masking / env-refs / clears are
|
||||
* explicit and survive round-trip without a magic `'********'`
|
||||
* literal sneaking back into config.yaml
|
||||
* - the model field is a discovery-backed dropdown with manual
|
||||
* fallback — typing a literal still works, but Ollama-style
|
||||
* `/models` endpoints pre-populate the dropdown
|
||||
* - roles use a chip editor instead of a comma-separated string, so
|
||||
* values containing commas are no longer corrupted
|
||||
* - `aao_gateway` rows show a heuristic self-loop warning when the
|
||||
* endpoint host looks like the current AAO instance
|
||||
*/
|
||||
export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const llm: LlmConfigShape = config.llm ?? {};
|
||||
const workers: LlmWorker[] = Array.isArray(llm.workers) ? llm.workers : [];
|
||||
const retry = llm.retry ?? {};
|
||||
|
||||
const updateWorker = (index: number, patch: Partial<LlmWorker>) => {
|
||||
const next = workers.map((w, i) => (i === index ? { ...w, ...patch } : w));
|
||||
onChange('llm.workers', next);
|
||||
};
|
||||
|
||||
const removeWorker = (index: number) => {
|
||||
onChange('llm.workers', workers.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const moveWorker = (index: number, delta: number) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= workers.length) return;
|
||||
const next = [...workers];
|
||||
const [removed] = next.splice(index, 1);
|
||||
next.splice(target, 0, removed);
|
||||
onChange('llm.workers', next);
|
||||
};
|
||||
|
||||
const addWorker = () => {
|
||||
const next: LlmWorker = {
|
||||
id: `worker-${workers.length + 1}`,
|
||||
connectionType: 'direct',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
maxConcurrency: 1,
|
||||
roles: [],
|
||||
};
|
||||
onChange('llm.workers', [...workers, next]);
|
||||
};
|
||||
|
||||
// Pre-compute self-loop verdicts once per render so we don't recompute
|
||||
// URL parsing inside the row JSX. Endpoint-only dependency is enough:
|
||||
// connection_type is checked at render site.
|
||||
const selfLoopFlags = useMemo(
|
||||
() => workers.map(w => detectSelfLoop(w.endpoint)),
|
||||
[workers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">LLM Workers</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
このセクションは AAO がジョブ実行で <strong>呼び出す</strong> LLM 接続先 (workers)
|
||||
を定義します。AAO 自身を gateway として公開する設定は <em>LLM → Gateway Server</em>
|
||||
にあります。<br />
|
||||
ロール: <code>auto</code> (全 job 候補) / <code>fast</code> · <code>quality</code>
|
||||
(パフォーマンス profile) / <code>reflection</code> (reflection 専用) /{' '}
|
||||
<code>title</code> (タイトル生成専用)。複数指定可。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{workers.length === 0 && (
|
||||
<div className="text-xs text-slate-500 border border-dashed border-slate-200 rounded p-4 text-center">
|
||||
worker が未登録です。最低 1 つ追加してください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workers.map((w, i) => {
|
||||
const isGateway = w.connectionType === 'aao_gateway' || w.proxy === true;
|
||||
const showSelfLoop = isGateway && selfLoopFlags[i];
|
||||
const endpointOverridden = i === 0 && overriddenByEnv['llm.workers[0].endpoint'];
|
||||
const modelOverridden = i === 0 && overriddenByEnv['llm.workers[0].model'];
|
||||
return (
|
||||
<div key={i} className="border border-slate-200 rounded-lg p-4 space-y-3 relative">
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => moveWorker(i, -1)}
|
||||
disabled={i === 0}
|
||||
title="上に移動"
|
||||
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveWorker(i, 1)}
|
||||
disabled={i === workers.length - 1}
|
||||
title="下に移動"
|
||||
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeWorker(i)}
|
||||
title="この worker を削除"
|
||||
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel>ID</FieldLabel>
|
||||
<FieldInput value={w.id ?? ''} onChange={v => updateWorker(i, { id: v })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Connection type</FieldLabel>
|
||||
<select
|
||||
value={w.connectionType ?? (w.proxy === true ? 'aao_gateway' : 'direct')}
|
||||
onChange={e => {
|
||||
const next = e.target.value as 'direct' | 'aao_gateway';
|
||||
// Keep the legacy `proxy` flag in sync so an
|
||||
// operator who downgrades to a v1 build doesn't
|
||||
// lose the routing semantics.
|
||||
updateWorker(i, {
|
||||
connectionType: next,
|
||||
proxy: next === 'aao_gateway' ? true : undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
<option value="direct">Direct (Ollama / vLLM / llama.cpp)</option>
|
||||
<option value="aao_gateway">AAO Gateway</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldInput
|
||||
value={w.endpoint ?? ''}
|
||||
onChange={v => updateWorker(i, { endpoint: v })}
|
||||
disabled={!!endpointOverridden}
|
||||
disabledReason="OLLAMA_BASE_URL 環境変数で上書き中"
|
||||
placeholder={
|
||||
isGateway
|
||||
? 'http://gateway.example.com:9876/v1'
|
||||
: 'http://localhost:11434/v1'
|
||||
}
|
||||
/>
|
||||
{endpointOverridden && <EnvOverrideWarning />}
|
||||
{showSelfLoop && (
|
||||
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
||||
endpoint は自インスタンスを指しているように見えます (self-loop)。
|
||||
リバースプロキシ越しの場合はこの警告は無視できます。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>API key{isGateway ? ' (必須)' : ' (任意)'}</FieldLabel>
|
||||
<SecretInput
|
||||
rawValue={w.apiKey ?? ''}
|
||||
onChange={v => updateWorker(i, { apiKey: v === '' ? '' : v })}
|
||||
placeholder={isGateway ? 'sk-aao-...' : 'sk-... (任意)'}
|
||||
/>
|
||||
<HelpText>
|
||||
{isGateway ? (
|
||||
<>
|
||||
他 AAO の <em>LLM → Gateway Server</em> で発行した{' '}
|
||||
<code>sk-aao-*</code> を貼り付けてください。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。
|
||||
</>
|
||||
)}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>Model</FieldLabel>
|
||||
<ModelSelect
|
||||
value={w.model ?? ''}
|
||||
onChange={v => updateWorker(i, { model: v || undefined })}
|
||||
endpoint={w.endpoint}
|
||||
apiKeyRaw={w.apiKey}
|
||||
/>
|
||||
{modelOverridden && <EnvOverrideWarning />}
|
||||
<HelpText>
|
||||
endpoint が <code>/models</code> を返せば dropdown に候補が出ます。
|
||||
出ない場合 (auth が必要、proxy 越し等) は直接入力してください。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>Roles</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={Array.isArray(w.roles) ? w.roles : []}
|
||||
onChange={roles => updateWorker(i, { roles })}
|
||||
placeholder="auto / fast / quality / reflection / title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>最大同時実行数</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={w.maxConcurrency ?? 1}
|
||||
onChange={v => updateWorker(i, { maxConcurrency: Number(v) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5 pt-5 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.enabled !== false}
|
||||
onChange={e => updateWorker(i, { enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
有効
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
|
||||
title="VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.vlm === true}
|
||||
onChange={e => updateWorker(i, { vlm: e.target.checked || undefined })}
|
||||
className="rounded"
|
||||
/>
|
||||
VLM
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={addWorker}
|
||||
className="px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft"
|
||||
>
|
||||
+ Worker を追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Global LLM Settings
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Timeout (minutes)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={llm.timeoutMinutes ?? 10}
|
||||
onChange={v => onChange('llm.timeoutMinutes', Number(v))}
|
||||
/>
|
||||
<HelpText>LLM リクエストのタイムアウト (分)。デフォルト: 10</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Retry (per-call HTTP)
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Attempts</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={retry.maxAttempts ?? 3}
|
||||
onChange={v => onChange('llm.retry.maxAttempts', Number(v))}
|
||||
/>
|
||||
<HelpText>1 回の LLM API 呼び出しでの最大試行回数</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Backoff (ms)</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={Array.isArray(retry.backoffMs) ? retry.backoffMs.map(n => String(n)) : []}
|
||||
onChange={vs => {
|
||||
const nums = vs.map(s => Number(s.trim())).filter(n => !isNaN(n));
|
||||
onChange('llm.retry.backoffMs', nums);
|
||||
}}
|
||||
placeholder="2000"
|
||||
/>
|
||||
<HelpText>各リトライ間の待機時間 (ms)。配列順に消費されます。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Retryable Status Codes</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={Array.isArray(retry.retryableStatus) ? retry.retryableStatus.map(n => String(n)) : []}
|
||||
onChange={vs => {
|
||||
const nums = vs.map(s => Number(s.trim())).filter(n => !isNaN(n));
|
||||
onChange('llm.retry.retryableStatus', nums);
|
||||
}}
|
||||
placeholder="429"
|
||||
/>
|
||||
<HelpText>リトライ対象の HTTP ステータスコード。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
// keep in sync with src/mcp/config.ts McpRuntimeConfig
|
||||
interface McpRuntimeConfig {
|
||||
callTimeoutSeconds: number;
|
||||
maxBinarySizeMb: number;
|
||||
maxOutputFilesPerJob: number;
|
||||
maxOutputSizeMbPerJob: number;
|
||||
toolCacheTtlSeconds: number;
|
||||
oauthPendingTtlMinutes: number;
|
||||
allowPrivateAddresses: boolean;
|
||||
}
|
||||
|
||||
export function McpForm({ config, onChange }: SectionFormProps) {
|
||||
const mcp: Partial<McpRuntimeConfig> = config.mcp ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">MCP</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
外部 MCP (Model Context Protocol) サーバーの接続・実行に関する設定です。
|
||||
接続先サーバーを追加する場合は、各タスクまたは設定から MCP サーバー URL を指定してください。
|
||||
</p>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">セキュリティ</h3>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mcp.allowPrivateAddresses === true}
|
||||
onChange={e => onChange('mcp.allowPrivateAddresses', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
プライベート IP への接続を許可する (self-hosted / localhost MCP サーバー用)
|
||||
</label>
|
||||
<HelpText>
|
||||
有効にすると、localhost・LAN アドレス (192.168.x.x, 10.x.x.x 等) への MCP 接続を許可します。
|
||||
SSRF リスクがあるため、信頼できるネットワーク環境でのみ使用してください。デフォルト: 無効
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">タイムアウト / キャッシュ</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール呼び出しタイムアウト (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.callTimeoutSeconds ?? 60}
|
||||
onChange={v => onChange('mcp.callTimeoutSeconds', Number(v))} />
|
||||
<HelpText>MCP ツールの 1 回の呼び出しに許容する最大時間(秒)。デフォルト: 60</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール一覧キャッシュ TTL (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.toolCacheTtlSeconds ?? 600}
|
||||
onChange={v => onChange('mcp.toolCacheTtlSeconds', Number(v))} />
|
||||
<HelpText>MCP サーバーから取得したツール一覧をキャッシュする時間(秒)。デフォルト: 600</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>OAuth pending state TTL (分)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.oauthPendingTtlMinutes ?? 10}
|
||||
onChange={v => onChange('mcp.oauthPendingTtlMinutes', Number(v))} />
|
||||
<HelpText>MCP OAuth 認可フローの pending 状態を保持する時間(分)。デフォルト: 10</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">容量制限</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール出力バイナリ 1 個あたり最大サイズ (MB)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxBinarySizeMb ?? 20}
|
||||
onChange={v => onChange('mcp.maxBinarySizeMb', Number(v))} />
|
||||
<HelpText>MCP ツールが返すバイナリ出力 1 ファイルの最大サイズ(MB)。デフォルト: 20</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ジョブあたり最大バイナリファイル数</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxOutputFilesPerJob ?? 10}
|
||||
onChange={v => onChange('mcp.maxOutputFilesPerJob', Number(v))} />
|
||||
<HelpText>1 ジョブで MCP ツールが保存できるバイナリファイルの最大数。デフォルト: 10</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ジョブあたり最大バイナリ合計サイズ (MB)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxOutputSizeMbPerJob ?? 200}
|
||||
onChange={v => onChange('mcp.maxOutputSizeMbPerJob', Number(v))} />
|
||||
<HelpText>1 ジョブで MCP ツールが保存できるバイナリ出力の合計最大サイズ(MB)。デフォルト: 200</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
/**
|
||||
* MemoryLearningForm.tsx — "Memory & Learning" settings section
|
||||
*
|
||||
* Two stacked panels:
|
||||
* 1. MemoryEntriesPanel — list / inline-edit / delete user memory entries
|
||||
* 2. ReflectionTimelinePanel — paged snapshot history + revert + 30-day metrics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
|
||||
|
||||
// Mirrors the server's flat shape from `listMemoryEntries` in
|
||||
// src/user-folder/memory.ts and `GET /api/local/memory/entries` in
|
||||
// src/bridge/memory-api.ts. If you change this shape, update both.
|
||||
interface MemoryEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
type: MemoryType;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface MemoryListResponse {
|
||||
entries: MemoryEntry[];
|
||||
index: string;
|
||||
}
|
||||
|
||||
interface SnapshotIndexEntry {
|
||||
ts: string;
|
||||
snapshotId: string;
|
||||
jobId: string;
|
||||
pieceName: string;
|
||||
memoryChanges: number;
|
||||
pieceEdited: boolean;
|
||||
reverted: boolean;
|
||||
// outcome appears in detail, not in index — fetched lazily
|
||||
}
|
||||
|
||||
interface SnapshotDetail {
|
||||
snapshotId: string;
|
||||
ts: string;
|
||||
originalJobId: string;
|
||||
userId: string;
|
||||
pieceName: string;
|
||||
outcome: string;
|
||||
reasoning: string;
|
||||
modelUsed?: string;
|
||||
tokensIn?: number;
|
||||
tokensOut?: number;
|
||||
ratingAtTime?: 'good' | 'bad' | null;
|
||||
memoryChanges: number;
|
||||
pieceEdited: boolean;
|
||||
rejections?: Array<{ code: string; name?: string }>;
|
||||
beforeFiles: Record<string, string>;
|
||||
afterFiles: Record<string, string>;
|
||||
pieceBeforeYaml?: string;
|
||||
pieceAfterYaml?: string;
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
interface HistoryPage {
|
||||
items: SnapshotIndexEntry[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
interface ReflectionMetrics {
|
||||
applied: number;
|
||||
partial: number;
|
||||
abstained: number;
|
||||
rejected: number;
|
||||
failed: number;
|
||||
tokensIn: number;
|
||||
tokensOut: number;
|
||||
pieceEdits: number;
|
||||
}
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchMemoryEntries(): Promise<MemoryListResponse> {
|
||||
const res = await fetch('/api/local/memory/entries');
|
||||
if (!res.ok) throw new Error(`メモリエントリの読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function upsertMemoryEntry(
|
||||
name: string,
|
||||
payload: { description: string; type: MemoryType; body: string },
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (!res.ok) throw new Error(data.error ?? res.statusText);
|
||||
}
|
||||
|
||||
async function deleteMemoryEntry(name: string): Promise<void> {
|
||||
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error ?? res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistoryPage(cursor?: string): Promise<HistoryPage> {
|
||||
const params = new URLSearchParams({ limit: '20' });
|
||||
if (cursor) params.set('before', cursor);
|
||||
const res = await fetch(`/api/local/reflection/history?${params}`);
|
||||
if (!res.ok) throw new Error(`履歴の読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchSnapshotDetail(snapshotId: string): Promise<SnapshotDetail> {
|
||||
const res = await fetch(`/api/local/reflection/history/${encodeURIComponent(snapshotId)}`);
|
||||
if (!res.ok) throw new Error(`スナップショットの読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function revertSnapshot(snapshotId: string): Promise<{ reverted: boolean }> {
|
||||
const res = await fetch(
|
||||
`/api/local/reflection/history/${encodeURIComponent(snapshotId)}/revert`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error ?? res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchMetrics(days: number = 30): Promise<ReflectionMetrics> {
|
||||
const res = await fetch(`/api/local/reflection/metrics?days=${days}`);
|
||||
if (!res.ok) throw new Error(`メトリクスの読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Shared UI primitives ──────────────────────────────────────────────────────
|
||||
|
||||
const OUTCOME_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
applied: { label: '適用済み', cls: 'bg-emerald-100 text-emerald-800' },
|
||||
partial: { label: '一部適用', cls: 'bg-yellow-100 text-yellow-800' },
|
||||
abstained: { label: '学習なし', cls: 'bg-slate-100 text-slate-600' },
|
||||
rejected: { label: '却下', cls: 'bg-red-100 text-red-700' },
|
||||
failed: { label: '失敗', cls: 'bg-red-200 text-red-900' },
|
||||
};
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: string }) {
|
||||
const { label, cls } = OUTCOME_LABELS[outcome] ?? { label: outcome, cls: 'bg-slate-100 text-slate-600' };
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTs(ts: string): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validator rejection code messages ─────────────────────────────────────────
|
||||
|
||||
const REJECTION_MESSAGES: Record<string, string> = {
|
||||
rejected_bad_name: '名前が無効です(英数字・ハイフン・アンダースコア、1〜64文字)',
|
||||
rejected_bad_description: '概要は必須で、1行以内で入力してください',
|
||||
rejected_unknown_type: 'タイプは user / feedback / project / reference のいずれかを指定してください',
|
||||
rejected_bad_body: '本文は文字列で指定してください',
|
||||
rejected_body_too_large: '本文が許容サイズを超えています',
|
||||
rejected_bad_request: 'リクエストの形式が正しくありません',
|
||||
};
|
||||
|
||||
function rejectionMessage(code: string): string {
|
||||
return REJECTION_MESSAGES[code] ?? code;
|
||||
}
|
||||
|
||||
// ── MemoryEntryModal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface EntryFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
type: MemoryType;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MEMORY_TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
|
||||
|
||||
function MemoryEntryModal({
|
||||
initial,
|
||||
isNew,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
initial: EntryFormState;
|
||||
isNew: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<EntryFormState>(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const set = <K extends keyof EntryFormState>(k: K, v: EntryFormState[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await upsertMemoryEntry(form.name, {
|
||||
description: form.description,
|
||||
type: form.type,
|
||||
body: form.body,
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(rejectionMessage(e.message));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline">
|
||||
<h3 className="text-sm font-semibold text-slate-800">
|
||||
{isNew ? '新しいメモリエントリ' : `編集 — ${initial.name}`}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-700 text-lg leading-none"
|
||||
aria-label="閉じる"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-3 flex-1">
|
||||
{/* Name — only editable when creating */}
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">
|
||||
名前
|
||||
{isNew && <span className="text-slate-400 ml-1">(英数字・ハイフン・アンダースコア)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
disabled={!isNew}
|
||||
placeholder="my-fact"
|
||||
className={`w-full h-8 px-2.5 text-[13px] font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${
|
||||
!isNew ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : 'bg-white'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">概要</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={e => set('description', e.target.value)}
|
||||
placeholder="メモリ一覧に表示される1行の説明"
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">タイプ</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={e => set('type', e.target.value as MemoryType)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring outline-none bg-white"
|
||||
>
|
||||
{MEMORY_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<HelpText>
|
||||
user: あなた固有の好み・役割 / feedback: 過去のフィードバック・教訓 / project: プロジェクト別の文脈 / reference: 参照資料・外部情報
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">本文</label>
|
||||
<textarea
|
||||
value={form.body}
|
||||
onChange={e => set('body', e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-2.5 py-2 text-xs font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none resize-y"
|
||||
placeholder="Markdown またはプレーンテキスト…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 border border-red-200 px-3 py-2 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-4 py-3 border-t border-hairline">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 h-8 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !form.name.trim() || !form.description.trim()}
|
||||
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MemoryEntriesPanel ────────────────────────────────────────────────────────
|
||||
|
||||
function MemoryEntriesPanel() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery<MemoryListResponse>({
|
||||
queryKey: ['memory-entries'],
|
||||
queryFn: fetchMemoryEntries,
|
||||
});
|
||||
|
||||
const [modal, setModal] = useState<{ entry: EntryFormState; isNew: boolean } | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleNew = () => {
|
||||
setModal({
|
||||
isNew: true,
|
||||
entry: { name: '', description: '', type: 'user', body: '' },
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (e: MemoryEntry) => {
|
||||
setModal({
|
||||
isNew: false,
|
||||
entry: {
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
type: e.type,
|
||||
body: e.body,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!confirm(`メモリエントリ「${name}」を削除しますか?`)) return;
|
||||
setDeleting(name);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteMemoryEntry(name);
|
||||
await qc.invalidateQueries({ queryKey: ['memory-entries'] });
|
||||
} catch (e: any) {
|
||||
setDeleteError(`「${name}」の削除に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaved = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['memory-entries'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-hairline">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline bg-surface rounded-t-lg">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-800">メモリエントリ</h3>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
エージェントの毎セッションに注入される永続的な情報。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNew}
|
||||
className="px-2.5 h-7 text-2xs font-medium bg-accent text-accent-fg rounded-md hover:bg-accent-deep transition-colors"
|
||||
>
|
||||
+ 新しいエントリ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="px-4 py-6 text-xs text-slate-400 text-center">読み込み中…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 text-xs text-red-600">
|
||||
メモリエントリの読み込みに失敗しました: {String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteError && (
|
||||
<div className="px-4 py-2 text-xs text-red-600 bg-red-50">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.entries.length === 0 && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-xs text-slate-400">メモリエントリはまだありません。</p>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
タスク完了後に reflection エンジンが自動で追加します。
|
||||
手動で追加することもできます。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.entries.length > 0 && (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{data.entries.map(entry => (
|
||||
<li key={entry.name} className="flex items-start gap-3 px-4 py-3 hover:bg-surface/60 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono font-medium text-slate-800 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 flex-shrink-0">
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 mt-0.5 truncate">{entry.description}</p>
|
||||
{entry.body && (
|
||||
<p className="text-2xs text-slate-400 mt-0.5 line-clamp-2 font-mono whitespace-pre-wrap break-words">
|
||||
{entry.body.slice(0, 200)}{entry.body.length > 200 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="px-2 h-6 text-2xs text-slate-600 border border-hairline bg-white hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(entry.name)}
|
||||
disabled={deleting === entry.name}
|
||||
className="px-2 h-6 text-2xs text-red-700 border border-red-200 bg-white hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === entry.name ? '…' : '削除'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<MemoryEntryModal
|
||||
initial={modal.entry}
|
||||
isNew={modal.isNew}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SnapshotCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onReverted: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [confirmRevert, setConfirmRevert] = useState(false);
|
||||
const [revertDone, setRevertDone] = useState<boolean | null>(null);
|
||||
|
||||
const detailQuery = useQuery<SnapshotDetail>({
|
||||
queryKey: ['snapshot-detail', item.snapshotId],
|
||||
queryFn: () => fetchSnapshotDetail(item.snapshotId),
|
||||
enabled: expanded,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const revertMutation = useMutation({
|
||||
mutationFn: () => revertSnapshot(item.snapshotId),
|
||||
onSuccess: (result) => {
|
||||
setRevertDone(result.reverted);
|
||||
setConfirmRevert(false);
|
||||
if (result.reverted) onReverted();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`border border-hairline rounded-md overflow-hidden ${item.reverted ? 'opacity-60' : ''}`}>
|
||||
{/* Header row — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(p => !p)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-surface/60 transition-colors"
|
||||
>
|
||||
<span className="text-2xs text-slate-400 flex-shrink-0 w-32 truncate" title={item.ts}>
|
||||
{formatTs(item.ts)}
|
||||
</span>
|
||||
<span className="text-2xs font-mono text-slate-700 truncate flex-1" title={item.pieceName}>
|
||||
{item.pieceName}
|
||||
</span>
|
||||
<span className="flex-shrink-0 flex items-center gap-1.5">
|
||||
{item.memoryChanges > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded">
|
||||
{item.memoryChanges} mem
|
||||
</span>
|
||||
)}
|
||||
{item.pieceEdited && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded">
|
||||
piece
|
||||
</span>
|
||||
)}
|
||||
{item.reverted && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded">
|
||||
revert済み
|
||||
</span>
|
||||
)}
|
||||
{detailQuery.data && <OutcomeBadge outcome={detailQuery.data.outcome} />}
|
||||
</span>
|
||||
<span className="text-slate-400 text-xs flex-shrink-0">{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div className="border-t border-hairline bg-slate-50 px-3 py-3 space-y-3">
|
||||
{detailQuery.isLoading && (
|
||||
<div className="text-xs text-slate-400">詳細を読み込み中…</div>
|
||||
)}
|
||||
{detailQuery.error && (
|
||||
<div className="text-xs text-red-600">
|
||||
読み込みに失敗しました: {String(detailQuery.error)}
|
||||
</div>
|
||||
)}
|
||||
{detailQuery.data && (() => {
|
||||
const d = detailQuery.data;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<OutcomeBadge outcome={d.outcome} />
|
||||
{d.modelUsed && (
|
||||
<span className="text-[10px] text-slate-400">{d.modelUsed}</span>
|
||||
)}
|
||||
{(d.tokensIn || d.tokensOut) && (
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{(d.tokensIn ?? 0).toLocaleString()} in / {(d.tokensOut ?? 0).toLocaleString()} out tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{d.reasoning && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
推論
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 whitespace-pre-wrap">{d.reasoning}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.rejections && d.rejections.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
却下理由
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{d.rejections.map((r, i) => (
|
||||
<li key={i} className="text-2xs text-red-700">
|
||||
<span className="font-mono">{r.code}</span>
|
||||
{r.name && <span className="text-slate-500 ml-1">({r.name})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.diff && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
変更内容
|
||||
</div>
|
||||
<pre className="text-2xs text-slate-700 bg-white border border-hairline rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap">
|
||||
{d.diff}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Before / After file diff */}
|
||||
{(Object.keys(d.beforeFiles).length > 0 || Object.keys(d.afterFiles).length > 0) && (
|
||||
<BeforeAfterDiff beforeFiles={d.beforeFiles} afterFiles={d.afterFiles} />
|
||||
)}
|
||||
|
||||
{/* Piece diff */}
|
||||
{d.pieceEdited && d.pieceBeforeYaml && d.pieceAfterYaml && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
Piece の差分
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更前</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{d.pieceBeforeYaml}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更後</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{d.pieceAfterYaml}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revert controls */}
|
||||
{!item.reverted && (
|
||||
<div className="pt-1">
|
||||
{revertDone === true && (
|
||||
<span className="text-xs text-emerald-700">正常に revert しました。</span>
|
||||
)}
|
||||
{revertDone === false && (
|
||||
<span className="text-xs text-slate-500">すでに revert 済みです。</span>
|
||||
)}
|
||||
{revertDone === null && !confirmRevert && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRevert(true)}
|
||||
className="px-2.5 h-7 text-2xs text-amber-800 border border-amber-300 bg-amber-50 hover:bg-amber-100 rounded transition-colors"
|
||||
>
|
||||
このスナップショットを revert…
|
||||
</button>
|
||||
)}
|
||||
{revertDone === null && confirmRevert && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-amber-800">
|
||||
このスナップショットの変更前の状態に戻しますか?
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revertMutation.mutate()}
|
||||
disabled={revertMutation.isPending}
|
||||
className="px-2.5 h-7 text-2xs font-semibold bg-red-600 text-white hover:bg-red-700 rounded disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{revertMutation.isPending ? 'revert 中…' : 'revert を確定'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRevert(false)}
|
||||
className="px-2.5 h-7 text-2xs text-slate-600 border border-hairline bg-white hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{revertMutation.isError && (
|
||||
<div className="text-2xs text-red-600 mt-1">
|
||||
{String(revertMutation.error)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BeforeAfterDiff ───────────────────────────────────────────────────────────
|
||||
|
||||
function BeforeAfterDiff({
|
||||
beforeFiles,
|
||||
afterFiles,
|
||||
}: {
|
||||
beforeFiles: Record<string, string>;
|
||||
afterFiles: Record<string, string>;
|
||||
}) {
|
||||
const allNames = Array.from(
|
||||
new Set([...Object.keys(beforeFiles), ...Object.keys(afterFiles)]),
|
||||
).sort();
|
||||
|
||||
if (allNames.length === 0) return null;
|
||||
|
||||
const [selected, setSelected] = useState(allNames[0]);
|
||||
|
||||
const before = beforeFiles[selected];
|
||||
const after = afterFiles[selected];
|
||||
const isAdded = !before && !!after;
|
||||
const isRemoved = !!before && !after;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
メモリファイルの差分
|
||||
</div>
|
||||
{allNames.length > 1 && (
|
||||
<div className="flex gap-1 mb-2 flex-wrap">
|
||||
{allNames.map(n => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setSelected(n)}
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded border ${
|
||||
selected === n
|
||||
? 'border-accent bg-accent-soft text-accent font-semibold'
|
||||
: 'border-hairline text-slate-500 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdded && (
|
||||
<div className="text-2xs text-emerald-700 bg-emerald-50 border border-emerald-200 rounded px-2 py-1 mb-1">
|
||||
追加
|
||||
</div>
|
||||
)}
|
||||
{isRemoved && (
|
||||
<div className="text-2xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 mb-1">
|
||||
削除
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{!isAdded && (
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更前</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{before ?? '(空)'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!isRemoved && (
|
||||
<div className={isAdded ? 'col-span-2' : ''}>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更後</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{after ?? '(空)'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MetricsSummary ────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricsSummary() {
|
||||
const { data, isLoading, error } = useQuery<ReflectionMetrics>({
|
||||
queryKey: ['reflection-metrics', 30],
|
||||
queryFn: () => fetchMetrics(30),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-xs text-slate-400 px-4 py-3">メトリクスを読み込み中…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-xs text-red-600 px-4 py-3">
|
||||
メトリクスの読み込みに失敗しました: {String(error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const totalRuns = data.applied + data.partial + data.abstained + data.rejected + data.failed;
|
||||
const appliedPct = totalRuns > 0 ? Math.round(((data.applied + data.partial) / totalRuns) * 100) : 0;
|
||||
const abstainPct = totalRuns > 0 ? Math.round((data.abstained / totalRuns) * 100) : 0;
|
||||
const totalTokens = data.tokensIn + data.tokensOut;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 bg-slate-50 border-t border-hairline rounded-b-lg">
|
||||
<div className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
30日間のサマリ
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ label: '合計実行回数', value: String(totalRuns) },
|
||||
{ label: '適用率', value: `${appliedPct}%` },
|
||||
{ label: '学習なし率', value: `${abstainPct}%` },
|
||||
{ label: 'Tokens', value: totalTokens > 1000 ? `${Math.round(totalTokens / 1000)}k` : String(totalTokens) },
|
||||
{ label: 'Piece 編集', value: String(data.pieceEdits) },
|
||||
].map(({ label, value }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="bg-white border border-hairline rounded px-2 py-1.5 text-center"
|
||||
>
|
||||
<div className="text-2xs font-semibold text-slate-800">{value}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalRuns === 0 && (
|
||||
<p className="text-2xs text-slate-400 mt-2">
|
||||
まだ reflection の実行履歴がありません。最初の reflection が完了するとメトリクスが表示されます。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ReflectionTimelinePanel ───────────────────────────────────────────────────
|
||||
|
||||
const OUTCOME_FILTER_OPTIONS = [
|
||||
{ value: 'applied', label: '適用済み' },
|
||||
{ value: 'partial', label: '一部適用' },
|
||||
{ value: 'abstained', label: '学習なし' },
|
||||
{ value: 'rejected', label: '却下' },
|
||||
{ value: 'failed', label: '失敗' },
|
||||
];
|
||||
|
||||
function ReflectionTimelinePanel() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Filters (client-side — the backend doesn't support filtering natively)
|
||||
const [outcomeFilter, setOutcomeFilter] = useState<string[]>([]);
|
||||
const [includeReverted, setIncludeReverted] = useState(true);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery<HistoryPage>({
|
||||
queryKey: ['reflection-history'],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchHistoryPage(typeof pageParam === 'string' ? pageParam : undefined),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const allItems: SnapshotIndexEntry[] = (data?.pages ?? []).flatMap(p => p.items);
|
||||
|
||||
// Client-side filtering — outcome is on the detail, but index has `reverted`
|
||||
const filteredItems = allItems.filter(item => {
|
||||
if (!includeReverted && item.reverted) return false;
|
||||
// Outcome filtering is only possible after detail is loaded; skip if no filter set
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleReverted = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['reflection-history'] });
|
||||
void qc.invalidateQueries({ queryKey: ['reflection-metrics'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-hairline">
|
||||
<div className="px-4 py-3 border-b border-hairline bg-surface rounded-t-lg">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Reflection タイムライン</h3>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
reflection 実行の履歴。各行を展開すると推論・変更前後の差分・revert コントロールを確認できます。
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||
<label className="flex items-center gap-1.5 text-2xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeReverted}
|
||||
onChange={e => setIncludeReverted(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
revert 済みを表示
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-2xs text-slate-500">結果:</span>
|
||||
{OUTCOME_FILTER_OPTIONS.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-1 text-2xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outcomeFilter.length === 0 || outcomeFilter.includes(opt.value)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setOutcomeFilter(prev =>
|
||||
prev.length === 0 ? [] : prev.filter(v => v !== opt.value).concat(opt.value),
|
||||
);
|
||||
} else {
|
||||
setOutcomeFilter(prev => {
|
||||
const next = prev.length === 0
|
||||
? OUTCOME_FILTER_OPTIONS.map(o => o.value).filter(v => v !== opt.value)
|
||||
: prev.filter(v => v !== opt.value);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
{outcomeFilter.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutcomeFilter([])}
|
||||
className="text-[10px] text-accent underline"
|
||||
>
|
||||
リセット
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
{isLoading && (
|
||||
<div className="text-xs text-slate-400 text-center py-4">読み込み中…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-600 px-2">
|
||||
読み込みに失敗しました: {String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredItems.length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-xs text-slate-400">まだ reflection の実行履歴がありません。</p>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
タスク完了後に自動で reflection が実行されます。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.map(item => (
|
||||
<SnapshotCard key={item.snapshotId} item={item} onReverted={handleReverted} />
|
||||
))}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="px-3 h-8 text-xs text-slate-600 border border-hairline bg-white hover:bg-surface rounded-md disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isFetchingNextPage ? '読み込み中…' : 'さらに表示'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MetricsSummary />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MemoryLearningForm (root export) ──────────────────────────────────────────
|
||||
|
||||
export function MemoryLearningForm() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-slate-800">Memory & Learning</h2>
|
||||
<p className="text-xs text-slate-500 -mt-4">
|
||||
エージェントが毎セッション参照する永続的なメモリエントリを管理し、自動学習(reflection)の実行履歴を確認できます。
|
||||
</p>
|
||||
|
||||
<MemoryEntriesPanel />
|
||||
<ReflectionTimelinePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Metrics — worker (LLM) and gateway metrics endpoints.
|
||||
*
|
||||
* config v2 places worker metrics under `llm.metrics` and gateway metrics
|
||||
* under `gateway.metrics`. The fields are nearly identical: enable flag,
|
||||
* Prometheus prefix, bearer token, and allowed-hosts ACL.
|
||||
*
|
||||
* Step 3 introduces this as a navigation home — the underlying paths are
|
||||
* the v2 shape so values will appear correctly after #360/#362. If the
|
||||
* caller still has v1 data, both objects fall back to empty.
|
||||
*/
|
||||
function MetricsBlock({
|
||||
title,
|
||||
path,
|
||||
prefixDefault,
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
path: 'llm.metrics' | 'gateway.metrics';
|
||||
prefixDefault: string;
|
||||
config: any;
|
||||
onChange: (path: string, value: any) => void;
|
||||
}) {
|
||||
const root = path.split('.').reduce((acc: any, key) => (acc ?? {})[key], config) ?? {};
|
||||
return (
|
||||
<section className="space-y-4 border border-hairline rounded-md p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">{title}</h3>
|
||||
|
||||
<div>
|
||||
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={root.enabled === true}
|
||||
onChange={e => onChange(`${path}.enabled`, e.target.checked)}
|
||||
/>
|
||||
<span>有効化</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Prefix</FieldLabel>
|
||||
<FieldInput
|
||||
value={root.prefix ?? ''}
|
||||
onChange={v => onChange(`${path}.prefix`, v || undefined)}
|
||||
placeholder={prefixDefault}
|
||||
/>
|
||||
<HelpText>Prometheus metric 名の prefix(例: <code>{prefixDefault}</code>)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Bearer Token</FieldLabel>
|
||||
<FieldInput
|
||||
type="password"
|
||||
value={root.bearerToken ?? ''}
|
||||
onChange={v => onChange(`${path}.bearerToken`, v || undefined)}
|
||||
placeholder="env:METRICS_BEARER_TOKEN"
|
||||
/>
|
||||
<HelpText>
|
||||
<code>/metrics</code> エンドポイントへのアクセス時に要求される Bearer token。
|
||||
<code>env:NAME</code> で環境変数参照可。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Allowed Hosts</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={root.allowedHosts ?? []}
|
||||
onChange={v => onChange(`${path}.allowedHosts`, v)}
|
||||
placeholder="127.0.0.1 / ::1 / localhost"
|
||||
/>
|
||||
<HelpText>許可するクライアント host (IP / hostname)。空の場合は token のみで認証。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsForm({ config, onChange }: SectionFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Metrics</h2>
|
||||
<HelpText>
|
||||
LLM Worker と AAO Gateway Server の Prometheus 互換 metrics 設定。
|
||||
config v2 では <code className="font-mono">llm.metrics</code> と{' '}
|
||||
<code className="font-mono">gateway.metrics</code> に分離されています。
|
||||
</HelpText>
|
||||
|
||||
<MetricsBlock
|
||||
title="Worker Metrics (llm.metrics)"
|
||||
path="llm.metrics"
|
||||
prefixDefault="aao_worker"
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<MetricsBlock
|
||||
title="Gateway Metrics (gateway.metrics)"
|
||||
path="gateway.metrics"
|
||||
prefixDefault="aao_gateway"
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { parseSecretValue } from '../../api';
|
||||
|
||||
interface ModelSelectProps {
|
||||
/** Currently saved model name. Always shown even if discovery fails. */
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
/** LLM endpoint to probe `<endpoint>/models` against. */
|
||||
endpoint: string | undefined;
|
||||
/**
|
||||
* Raw `apiKey` string from the draft config. Used to attach a Bearer
|
||||
* token to the discovery request when it's a literal secret. Masked
|
||||
* / env_ref values cannot be used for direct discovery in Phase 1
|
||||
* (the actual literal is not exposed to the browser), and we fall
|
||||
* back to manual input in that case.
|
||||
*/
|
||||
apiKeyRaw: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint + apiKey -aware model dropdown with a manual-input fallback.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Probes `<endpoint>/models` once whenever endpoint / apiKey
|
||||
* identity changes.
|
||||
* - Success → renders a searchable dropdown of returned ids; the
|
||||
* currently saved `value` is always included even if discovery
|
||||
* dropped it (so a typo doesn't silently overwrite the choice).
|
||||
* - Failure (network error, non-2xx, malformed body) → renders a
|
||||
* plain text input and shows an inline amber warning suggesting
|
||||
* manual entry.
|
||||
* - apiKey is `unchanged` / `env_ref` / `cleared` → also falls back
|
||||
* to manual input. Probing with the masked sentinel would 401 and
|
||||
* leak nothing useful.
|
||||
*
|
||||
* The component is deliberately self-contained: the parent passes the
|
||||
* current draft endpoint+apiKey and the new model name flows back via
|
||||
* `onChange`. No global state, no caching across remounts — discovery
|
||||
* latency is short enough (< 1s typically) that re-probing on every
|
||||
* mount is fine, and avoids stale dropdowns if the endpoint changed.
|
||||
*/
|
||||
export function ModelSelect({ value, onChange, endpoint, apiKeyRaw }: ModelSelectProps) {
|
||||
const [models, setModels] = useState<string[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Track the in-flight probe so a fast endpoint edit cancels the
|
||||
// previous one instead of racing the latest write.
|
||||
const probeIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint || endpoint.trim() === '') {
|
||||
setModels(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
const parsed = parseSecretValue(apiKeyRaw);
|
||||
// Phase 1: only `literal` keys can be used for direct discovery
|
||||
// from the browser. For `env_ref` / `unchanged` the literal is
|
||||
// server-side only — manual fallback. For `cleared` we attempt
|
||||
// discovery without an Authorization header (works for Ollama).
|
||||
const bearer =
|
||||
parsed.type === 'literal' ? parsed.value
|
||||
: parsed.type === 'cleared' ? undefined
|
||||
: null; // null = skip discovery
|
||||
if (bearer === null) {
|
||||
setModels(null);
|
||||
setError('API key is masked or env-ref; please enter the model name manually.');
|
||||
return;
|
||||
}
|
||||
const probeId = ++probeIdRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const trimmed = endpoint.replace(/\/+$/, '');
|
||||
const url = `${trimmed}/models`;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
fetch(url, { headers })
|
||||
.then(async res => {
|
||||
if (probeId !== probeIdRef.current) return; // stale
|
||||
if (!res.ok) {
|
||||
setModels(null);
|
||||
setError(`model discovery failed (HTTP ${res.status}); please enter manually.`);
|
||||
return;
|
||||
}
|
||||
const body = await res.json().catch(() => null) as { data?: Array<{ id?: unknown }> } | null;
|
||||
if (!body || !Array.isArray(body.data)) {
|
||||
setModels(null);
|
||||
setError('model discovery returned an unexpected payload; please enter manually.');
|
||||
return;
|
||||
}
|
||||
const ids = body.data
|
||||
.map(m => (typeof m?.id === 'string' ? m.id.trim() : ''))
|
||||
.filter(id => id.length > 0);
|
||||
// Surface the discovered set even if empty — distinguishes
|
||||
// "endpoint reachable, no models loaded" from "endpoint down".
|
||||
setModels(Array.from(new Set(ids)));
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
if (probeId !== probeIdRef.current) return;
|
||||
setModels(null);
|
||||
setError(
|
||||
`model discovery failed (${err instanceof Error ? err.message : 'network error'}); ` +
|
||||
'please enter manually.',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (probeId === probeIdRef.current) setLoading(false);
|
||||
});
|
||||
}, [endpoint, apiKeyRaw]);
|
||||
|
||||
// Discovery succeeded — render a datalist-backed combobox so the user
|
||||
// can either pick or override. A native datalist is the simplest way
|
||||
// to get "dropdown with manual fallback" without a custom popover.
|
||||
if (models !== null) {
|
||||
const options = value && !models.includes(value) ? [value, ...models] : models;
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
list="llm-workers-model-options"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={loading ? 'loading...' : 'choose or type a model'}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
/>
|
||||
<datalist id="llm-workers-model-options">
|
||||
{options.map(m => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
{options.length === 0 && (
|
||||
<p className="text-2xs text-slate-500 mt-1">
|
||||
endpoint reachable but no models reported.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manual fallback (discovery failed or skipped).
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={loading ? 'loading...' : 'qwen3:8b'}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-100 px-2 py-1 rounded mt-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { MovementForm } from './MovementForm';
|
||||
|
||||
export interface MovementAccordionProps {
|
||||
movements: any[];
|
||||
onChange: (index: number, field: string, value: any) => void;
|
||||
onAdd: () => void;
|
||||
onRemove: (index: number) => void;
|
||||
onMove: (index: number, direction: 'up' | 'down') => void;
|
||||
}
|
||||
|
||||
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove }: MovementAccordionProps) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const movementNames = movements.map((m) => m.name ?? '');
|
||||
|
||||
const toggle = (i: number) => {
|
||||
setExpandedIndex((prev) => (prev === i ? null : i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-800 mb-2">Movements</h3>
|
||||
<div className="space-y-2">
|
||||
{movements.map((movement, i) => {
|
||||
const isExpanded = expandedIndex === i;
|
||||
const toolCount = (movement.allowed_tools ?? []).length;
|
||||
const ruleCount = (movement.rules ?? []).length;
|
||||
|
||||
return (
|
||||
<div key={i} className="bg-white border border-slate-200 rounded-lg">
|
||||
{/* Collapsed header */}
|
||||
<div
|
||||
className="flex items-center gap-2 p-3 cursor-pointer select-none"
|
||||
onClick={() => toggle(i)}
|
||||
>
|
||||
<span className="text-xs text-slate-400 mr-1">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
<span className="text-sm font-medium text-slate-800">{movement.name || '(unnamed)'}</span>
|
||||
{movement.persona && (
|
||||
<span className="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded">
|
||||
{movement.persona}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${movement.edit ? 'bg-green-100 text-green-700' : 'bg-purple-100 text-purple-700'}`}>
|
||||
edit: {movement.edit ? 'on' : 'off'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{toolCount} tools</span>
|
||||
<span className="text-xs text-slate-400">{ruleCount} rules</span>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'up')}
|
||||
disabled={i === 0}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move up"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'down')}
|
||||
disabled={i === movements.length - 1}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move down"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
|
||||
onRemove(i);
|
||||
if (expandedIndex === i) setExpandedIndex(null);
|
||||
}
|
||||
}}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded form */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-slate-100 pt-3">
|
||||
<MovementForm
|
||||
movement={movement}
|
||||
movementNames={movementNames}
|
||||
onChange={(field, value) => onChange(i, field, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="mt-3 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Movement
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { ToolTagInput } from './ToolTagInput';
|
||||
import { RulesTable } from './RulesTable';
|
||||
|
||||
const SPECIAL_TARGETS = ['COMPLETE', 'ASK', 'ABORT', 'WAIT_SUBTASKS'];
|
||||
|
||||
export interface MovementFormProps {
|
||||
movement: any;
|
||||
movementNames: string[];
|
||||
onChange: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
export function MovementForm({ movement, movementNames, onChange }: MovementFormProps) {
|
||||
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={movement.name ?? ''}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* persona */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">persona</label>
|
||||
<input
|
||||
type="text"
|
||||
value={movement.persona ?? ''}
|
||||
onChange={(e) => onChange('persona', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* default_next */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">default_next</label>
|
||||
<select
|
||||
value={movement.default_next ?? 'COMPLETE'}
|
||||
onChange={(e) => onChange('default_next', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
{nextOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* edit */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`edit-${movement.name}`}
|
||||
checked={movement.edit ?? false}
|
||||
onChange={(e) => onChange('edit', e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
|
||||
<HelpText>有効にすると Write / Edit ツールが LLM に提示されます</HelpText>
|
||||
</div>
|
||||
|
||||
{/* instruction */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">instruction</label>
|
||||
<textarea
|
||||
value={movement.instruction ?? ''}
|
||||
onChange={(e) => onChange('instruction', e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono"
|
||||
/>
|
||||
<HelpText>LLM に渡される指示文。Markdown 記法が使えます</HelpText>
|
||||
</div>
|
||||
|
||||
{/* allowed_tools */}
|
||||
<ToolTagInput
|
||||
value={movement.allowed_tools ?? []}
|
||||
onChange={(tools) => onChange('allowed_tools', tools)}
|
||||
/>
|
||||
|
||||
{/* rules */}
|
||||
<RulesTable
|
||||
rules={movement.rules ?? []}
|
||||
movementNames={movementNames.filter((n) => n !== movement.name)}
|
||||
onChange={(rules) => onChange('rules', rules)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface NamespaceEditorProps {
|
||||
value: Record<string, { apiKey: string }>;
|
||||
onChange: (value: Record<string, { apiKey: string }>) => void;
|
||||
/**
|
||||
* Disable the "add namespace" controls (input fields + button). Existing
|
||||
* entries remain editable / removable. Used by the DKS [LEGACY] section
|
||||
* to steer new integrations toward MCP servers.
|
||||
*/
|
||||
addDisabled?: boolean;
|
||||
/** Tooltip shown on the disabled controls. */
|
||||
addDisabledReason?: string;
|
||||
/** Optional href surfaced alongside the tooltip (e.g. MCP help doc). */
|
||||
addDisabledHref?: string;
|
||||
}
|
||||
|
||||
export function NamespaceEditor({
|
||||
value,
|
||||
onChange,
|
||||
addDisabled = false,
|
||||
addDisabledReason,
|
||||
addDisabledHref,
|
||||
}: NamespaceEditorProps) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newApiKey, setNewApiKey] = useState('');
|
||||
|
||||
const entries = Object.entries(value);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (addDisabled) return;
|
||||
const name = newName.trim();
|
||||
if (!name || name in value) return;
|
||||
onChange({ ...value, [name]: { apiKey: newApiKey } });
|
||||
setNewName('');
|
||||
setNewApiKey('');
|
||||
};
|
||||
|
||||
const handleRemove = (name: string) => {
|
||||
const { [name]: _, ...rest } = value;
|
||||
onChange(rest);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (name: string, apiKey: string) => {
|
||||
onChange({ ...value, [name]: { apiKey } });
|
||||
};
|
||||
|
||||
const disabledTitle = addDisabled ? addDisabledReason : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([name, { apiKey }]) => (
|
||||
<div key={name} className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-700 min-w-[140px] truncate" title={name}>{name}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => handleApiKeyChange(name, e.target.value)}
|
||||
placeholder="API Key"
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemove(name)}
|
||||
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder="namespace"
|
||||
disabled={addDisabled}
|
||||
title={disabledTitle}
|
||||
className="w-[140px] px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newApiKey}
|
||||
onChange={e => setNewApiKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder="API Key"
|
||||
disabled={addDisabled}
|
||||
title={disabledTitle}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={addDisabled}
|
||||
title={disabledTitle}
|
||||
className="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed disabled:hover:bg-slate-200"
|
||||
aria-label={addDisabled ? '新規追加は無効化されています' : '新規追加'}
|
||||
>+ 追加</button>
|
||||
{addDisabled && addDisabledHref && (
|
||||
<a
|
||||
href={addDisabledHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-2 py-1.5 text-xs text-accent underline self-center"
|
||||
title={disabledTitle}
|
||||
>MCP ガイド</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import {
|
||||
isNotificationSupported,
|
||||
getNotificationPermission,
|
||||
requestNotificationPermission,
|
||||
createNotification,
|
||||
buildNotificationOptions,
|
||||
DEFAULT_NOTIFY_EVENTS,
|
||||
type NotifyEventType,
|
||||
type NotifyEventSettings,
|
||||
} from '../../lib/notifications';
|
||||
import {
|
||||
isPushSupported,
|
||||
isStandalonePWA,
|
||||
isIOS,
|
||||
subscribePush,
|
||||
unsubscribePush,
|
||||
getCurrentPushSubscription,
|
||||
} from '../../lib/push-subscribe';
|
||||
import {
|
||||
fetchVapidPublicKey,
|
||||
listPushSubscriptions,
|
||||
postPushSubscription,
|
||||
deletePushSubscription as apiDeletePushSubscription,
|
||||
fetchNotificationPrefs,
|
||||
updateNotificationPrefs,
|
||||
migrateLocalStoragePrefs,
|
||||
postTestNotification,
|
||||
type PushSubscriptionPublic,
|
||||
type NotificationPrefsDTO,
|
||||
} from '../../api';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
const EVENT_LABELS: Array<{ key: NotifyEventType; label: string }> = [
|
||||
{ key: 'running', label: 'タスク開始 (running)' },
|
||||
{ key: 'succeeded', label: 'タスク完了 (succeeded)' },
|
||||
{ key: 'failed', label: 'タスク失敗 (failed / aborted)' },
|
||||
{ key: 'waiting_human', label: 'ユーザー回答待ち (waiting_human)' },
|
||||
];
|
||||
|
||||
type PushAvailability =
|
||||
| { kind: 'supported' }
|
||||
| { kind: 'needs-pwa-ios' }
|
||||
| { kind: 'unsupported'; reason: string };
|
||||
|
||||
function evaluatePushAvailability(): PushAvailability {
|
||||
if (!isPushSupported()) {
|
||||
return { kind: 'unsupported', reason: 'お使いのブラウザは Web Push API に対応していません' };
|
||||
}
|
||||
if (isIOS() && !isStandalonePWA()) {
|
||||
return { kind: 'needs-pwa-ios' };
|
||||
}
|
||||
return { kind: 'supported' };
|
||||
}
|
||||
|
||||
export function NotificationsForm() {
|
||||
const supported = isNotificationSupported();
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
|
||||
getNotificationPermission(),
|
||||
);
|
||||
// V1 localStorage (legacy fallback; server prefs override once loaded).
|
||||
const [v1Enabled, setV1Enabled] = useLocalStorageState<boolean>('notify.enabled', true);
|
||||
const [v1Events, setV1Events] = useLocalStorageState<NotifyEventSettings>(
|
||||
'notify.events',
|
||||
DEFAULT_NOTIFY_EVENTS,
|
||||
);
|
||||
|
||||
// V2 server-side state — null until first fetch / not configured.
|
||||
const [serverPrefs, setServerPrefs] = useState<NotificationPrefsDTO | null>(null);
|
||||
const [subscriptions, setSubscriptions] = useState<PushSubscriptionPublic[]>([]);
|
||||
const [pushAvailable] = useState<PushAvailability>(() => evaluatePushAvailability());
|
||||
const [hasLocalSubscription, setHasLocalSubscription] = useState<boolean>(false);
|
||||
const [pushFatal, setPushFatal] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
|
||||
// Effective prefs come from the server when available; otherwise fall back
|
||||
// to localStorage so V1-only deployments keep working unchanged.
|
||||
const enabled = serverPrefs?.enabled ?? v1Enabled;
|
||||
const events: NotifyEventSettings = serverPrefs
|
||||
? serverPrefs.events
|
||||
: v1Events;
|
||||
const includeDetails = serverPrefs?.includeDetails ?? false;
|
||||
|
||||
const setEnabled = useCallback(
|
||||
async (next: boolean) => {
|
||||
setV1Enabled(next);
|
||||
if (serverPrefs) {
|
||||
const updated = await updateNotificationPrefs({ enabled: next });
|
||||
setServerPrefs(updated);
|
||||
}
|
||||
},
|
||||
[serverPrefs, setV1Enabled],
|
||||
);
|
||||
|
||||
const toggleEvent = useCallback(
|
||||
async (key: NotifyEventType) => {
|
||||
const nextValue = !events[key];
|
||||
setV1Events(prev => ({ ...prev, [key]: nextValue }));
|
||||
if (serverPrefs) {
|
||||
const updated = await updateNotificationPrefs({ events: { [key]: nextValue } });
|
||||
setServerPrefs(updated);
|
||||
}
|
||||
},
|
||||
[events, serverPrefs, setV1Events],
|
||||
);
|
||||
|
||||
const setIncludeDetails = useCallback(
|
||||
async (next: boolean) => {
|
||||
if (!serverPrefs) return;
|
||||
const updated = await updateNotificationPrefs({ includeDetails: next });
|
||||
setServerPrefs(updated);
|
||||
},
|
||||
[serverPrefs],
|
||||
);
|
||||
|
||||
// First-load: hydrate server prefs, migrate from localStorage if needed.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const prefs = await fetchNotificationPrefs();
|
||||
if (cancelled) return;
|
||||
if (!prefs.v1Migrated) {
|
||||
// One-shot import from localStorage. 409 means another tab beat us;
|
||||
// in that case just adopt the server state.
|
||||
const result = await migrateLocalStoragePrefs({
|
||||
enabled: v1Enabled,
|
||||
events: v1Events,
|
||||
includeDetails: false,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if ('alreadyMigrated' in result) {
|
||||
setServerPrefs(prefs);
|
||||
} else {
|
||||
setServerPrefs(result.prefs);
|
||||
}
|
||||
} else {
|
||||
setServerPrefs(prefs);
|
||||
}
|
||||
} catch (err) {
|
||||
// /api/notifications/preferences not reachable → keep V1 fallback.
|
||||
// Logged for diagnostics; the UI remains usable.
|
||||
console.warn('[notifications] failed to load server prefs', err);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Subscriptions list — re-fetch on mount and after subscribe/unsubscribe.
|
||||
const refreshSubscriptions = useCallback(async () => {
|
||||
try {
|
||||
const list = await listPushSubscriptions();
|
||||
setSubscriptions(list);
|
||||
} catch (err) {
|
||||
console.warn('[notifications] failed to load subscriptions', err);
|
||||
}
|
||||
try {
|
||||
const local = await getCurrentPushSubscription();
|
||||
setHasLocalSubscription(local !== null);
|
||||
} catch {
|
||||
setHasLocalSubscription(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pushAvailable.kind !== 'supported') return;
|
||||
refreshSubscriptions();
|
||||
}, [pushAvailable.kind, refreshSubscriptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setPermission(getNotificationPermission());
|
||||
window.addEventListener('focus', refresh);
|
||||
return () => window.removeEventListener('focus', refresh);
|
||||
}, []);
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">ブラウザ通知</h3>
|
||||
<HelpText>お使いのブラウザは Notification API に未対応です。</HelpText>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleEnable = async () => {
|
||||
const result = await requestNotificationPermission();
|
||||
setPermission(result === 'unsupported' ? 'unsupported' : result);
|
||||
if (result === 'granted') void setEnabled(true);
|
||||
else void setEnabled(false);
|
||||
};
|
||||
|
||||
const handleTestV1 = () => {
|
||||
const opts = buildNotificationOptions(
|
||||
{ id: 0, title: 'テスト通知', pieceName: 'ブラウザ通知は正常に動作しています' },
|
||||
'succeeded',
|
||||
);
|
||||
createNotification(opts, () => { /* no-op */ });
|
||||
};
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
if (pushAvailable.kind !== 'supported') return;
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
const { publicKey } = await fetchVapidPublicKey();
|
||||
const dto = await subscribePush(publicKey);
|
||||
await postPushSubscription(dto);
|
||||
await refreshSubscriptions();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsubscribeLocal = async () => {
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
const local = await getCurrentPushSubscription();
|
||||
// Match the server-side row by endpoint host (server returns only the host).
|
||||
const targetHost = local ? (() => {
|
||||
try { return new URL(local.endpoint).host; } catch { return null; }
|
||||
})() : null;
|
||||
const serverRow = subscriptions.find(s => targetHost && s.endpointHost === targetHost);
|
||||
await unsubscribePush();
|
||||
if (serverRow) await apiDeletePushSubscription(serverRow.id);
|
||||
await refreshSubscriptions();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRemote = async (id: string) => {
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
await apiDeletePushSubscription(id);
|
||||
await refreshSubscriptions();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestV2 = async () => {
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
await postTestNotification();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const v1StatusBadge = (() => {
|
||||
if (permission === 'granted' && enabled) return '✅ 有効化済み';
|
||||
if (permission === 'granted' && !enabled) return '⏸ 一時停止中';
|
||||
if (permission === 'denied') return '🚫 ブラウザで拒否';
|
||||
return '❌ 未許可';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── V1: 前面通知 ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">ブラウザ通知 (V1: 前面表示)</h3>
|
||||
<p className="mt-1 text-[13px] text-slate-700">状態: {v1StatusBadge}</p>
|
||||
|
||||
{permission === 'default' && (
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="mt-2 px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px]"
|
||||
>
|
||||
ブラウザ通知を有効化
|
||||
</button>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<HelpText>
|
||||
ブラウザのアドレスバー左の設定アイコンから「通知」を許可に変更してください。
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
{permission === 'granted' && (
|
||||
<label className="mt-2 flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => void setEnabled(e.target.checked)}
|
||||
/>
|
||||
通知を受け取る (マスター ON/OFF)
|
||||
</label>
|
||||
)}
|
||||
|
||||
{permission === 'granted' && (
|
||||
<button
|
||||
onClick={handleTestV1}
|
||||
disabled={!enabled}
|
||||
className="mt-2 px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
テスト通知 (ページ内)
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── V2: モバイル / バックグラウンド通知 ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">📱 モバイル / バックグラウンド通知 (V2)</h3>
|
||||
|
||||
{pushAvailable.kind === 'unsupported' && (
|
||||
<HelpText>{pushAvailable.reason}</HelpText>
|
||||
)}
|
||||
|
||||
{pushAvailable.kind === 'needs-pwa-ios' && (
|
||||
<HelpText>
|
||||
iOS Safari では「共有 → ホーム画面に追加」でアプリとしてインストールしてから、
|
||||
ホーム画面のアイコンから開いた状態で通知を有効化できます。
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
{pushAvailable.kind === 'supported' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-[13px] text-slate-700">
|
||||
状態: {hasLocalSubscription ? '✅ このデバイスで購読中' : '❌ このデバイスは未購読'}
|
||||
{subscriptions.length > 0 && ` (合計 ${subscriptions.length} デバイス)`}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
disabled={busy || hasLocalSubscription || !enabled}
|
||||
className="px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px] disabled:opacity-50"
|
||||
>
|
||||
このデバイスで購読
|
||||
</button>
|
||||
{hasLocalSubscription && (
|
||||
<button
|
||||
onClick={handleUnsubscribeLocal}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
購読を解除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleTestV2}
|
||||
disabled={busy || subscriptions.length === 0}
|
||||
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
テスト通知 (サーバー経由)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{subscriptions.length > 0 && (
|
||||
<div className="mt-2 border border-slate-200 rounded">
|
||||
<p className="px-3 py-1 text-[12px] text-slate-600 border-b border-slate-200">
|
||||
購読デバイス一覧
|
||||
</p>
|
||||
{subscriptions.map(s => (
|
||||
<div key={s.id} className="flex items-center justify-between px-3 py-2 text-[13px] border-b border-slate-100 last:border-b-0">
|
||||
<div>
|
||||
<div className="truncate max-w-md">{s.userAgent ?? '(unknown)'}</div>
|
||||
<div className="text-[11px] text-slate-500">
|
||||
{s.endpointHost} • {new Date(s.createdAt).toLocaleString('ja-JP')}
|
||||
{s.failureCount > 0 && (
|
||||
<span className="ml-2 text-red-600">⚠ {s.failureCount} failures</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteRemote(s.id)}
|
||||
disabled={busy}
|
||||
className="ml-2 px-2 py-1 text-[11px] text-red-700 hover:bg-red-50 rounded"
|
||||
>
|
||||
解除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverPrefs && (
|
||||
<label className="mt-2 flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDetails}
|
||||
onChange={e => void setIncludeDetails(e.target.checked)}
|
||||
/>
|
||||
通知にタスクの詳細(タイトル・piece 名)を含める
|
||||
<span className="text-[11px] text-slate-500">
|
||||
(OFF: 「タスク #N 完了」のみ)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{pushFatal && (
|
||||
<p className="text-[12px] text-red-700">エラー: {pushFatal}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 通知するイベント (V1 + V2 共通) ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">通知するイベント</h3>
|
||||
<div className="mt-2 space-y-1">
|
||||
{EVENT_LABELS.map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events[key]}
|
||||
onChange={() => void toggleEvent(key)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HelpText>
|
||||
ⓘ V1 (前面表示) はタブが開いていてフォーカスがある時のみ動作します<br />
|
||||
ⓘ V2 (モバイル / バックグラウンド) は HTTPS + PWA インストール時のみ確実に動作します<br />
|
||||
ⓘ 自分が owner のタスクのみ通知されます
|
||||
</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Paths & Storage — config v2 `storage.*` block.
|
||||
*
|
||||
* Reads from the new `storage.*` keys emitted by `GET /api/config` (after
|
||||
* #360 normalization + #362 v2 API shape). Old flat keys (`worktreeDir` etc.)
|
||||
* are rejected by `PUT /api/config` since v2, so this form only writes
|
||||
* `storage.*`.
|
||||
*/
|
||||
export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const storage = config.storage ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Paths & Storage</h2>
|
||||
<HelpText>
|
||||
ファイルシステム上の保存先と上限の設定。config v2 では <code className="font-mono">storage.*</code> に集約されています。
|
||||
</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Worktree Directory</FieldLabel>
|
||||
<FieldInput
|
||||
value={storage.worktreeDir ?? ''}
|
||||
onChange={v => onChange('storage.worktreeDir', v || undefined)}
|
||||
disabled={!!overriddenByEnv['storage.worktreeDir'] || !!overriddenByEnv['worktreeDir']}
|
||||
disabledReason="WORKTREE_DIR 環境変数で上書き中"
|
||||
/>
|
||||
{(overriddenByEnv['storage.worktreeDir'] || overriddenByEnv['worktreeDir']) && <EnvOverrideWarning />}
|
||||
<HelpText>ジョブ実行時の作業ディレクトリのベースパス</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Custom Pieces Directory</FieldLabel>
|
||||
<FieldInput
|
||||
value={storage.customPiecesDir ?? ''}
|
||||
onChange={v => onChange('storage.customPiecesDir', v || undefined)}
|
||||
placeholder="/path/to/your/custom-pieces"
|
||||
/>
|
||||
<HelpText>リポジトリ内の pieces/ とは別に、追加の Piece を配置するディレクトリ。省略時は pieces/ のみ使用</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>User Folder Root</FieldLabel>
|
||||
<FieldInput
|
||||
value={storage.userFolderRoot ?? ''}
|
||||
onChange={v => onChange('storage.userFolderRoot', v || undefined)}
|
||||
placeholder="./data/users"
|
||||
/>
|
||||
<HelpText>ユーザーごとの設定・スクリプト・メモリ等を保存するルートディレクトリ</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Task Upload 最大サイズ (MB)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={storage.taskUploadMaxSizeMb ?? 50}
|
||||
onChange={v => onChange('storage.taskUploadMaxSizeMb', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>
|
||||
<code>POST /api/local/tasks</code> および <code>POST /api/local/tasks/:id/comments</code> の
|
||||
リクエスト body 上限。範囲 1〜1000 MB、デフォルト 50。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Trash Retention (日)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={storage.trashRetentionDays ?? 30}
|
||||
onChange={v => onChange('storage.trashRetentionDays', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>
|
||||
<code>data/users/{userId}/trash/</code> のファイルを自動削除するまでの日数。
|
||||
0 を指定すると即削除。デフォルト 30 日。
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { stringify, parse } from 'yaml';
|
||||
import { usePiece } from '../../hooks/usePieces';
|
||||
import { updatePiece, deletePiece } from '../../api';
|
||||
import { useUrlState } from '../../hooks/useUrlState';
|
||||
import { PieceMetaForm } from './PieceMetaForm';
|
||||
import { MovementAccordion } from './MovementAccordion';
|
||||
|
||||
export interface PieceEditorProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function PieceEditor({ name }: PieceEditorProps) {
|
||||
const { data: piece, isLoading, error } = usePiece(name);
|
||||
const queryClient = useQueryClient();
|
||||
const { setUrlState } = useUrlState();
|
||||
|
||||
const [draft, setDraft] = useState<any>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// YAML editing mode
|
||||
const [editMode, setEditMode] = useState<'visual' | 'yaml'>('visual');
|
||||
const [yamlText, setYamlText] = useState('');
|
||||
const [yamlError, setYamlError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (piece) {
|
||||
setDraft(structuredClone(piece));
|
||||
setIsDirty(false);
|
||||
setEditMode('visual');
|
||||
setYamlError(null);
|
||||
}
|
||||
}, [piece]);
|
||||
|
||||
const showToast = (msg: string, duration = 2000) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), duration);
|
||||
};
|
||||
|
||||
const handleMetaChange = useCallback((field: string, value: any) => {
|
||||
setDraft((prev: any) => {
|
||||
if (field === 'triggers.keywords') {
|
||||
return { ...prev, triggers: { ...prev.triggers, keywords: value } };
|
||||
}
|
||||
return { ...prev, [field]: value };
|
||||
});
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleMovementChange = useCallback((index: number, field: string, value: any) => {
|
||||
setDraft((prev: any) => {
|
||||
const movements = [...prev.movements];
|
||||
movements[index] = { ...movements[index], [field]: value };
|
||||
return { ...prev, movements };
|
||||
});
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleAddMovement = useCallback(() => {
|
||||
setDraft((prev: any) => ({
|
||||
...prev,
|
||||
movements: [
|
||||
...prev.movements,
|
||||
{
|
||||
name: `step_${prev.movements.length + 1}`,
|
||||
persona: '',
|
||||
default_next: 'COMPLETE',
|
||||
edit: false,
|
||||
instruction: '',
|
||||
allowed_tools: [],
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveMovement = useCallback((index: number) => {
|
||||
setDraft((prev: any) => ({
|
||||
...prev,
|
||||
movements: prev.movements.filter((_: any, i: number) => i !== index),
|
||||
}));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleMoveMovement = useCallback((index: number, direction: 'up' | 'down') => {
|
||||
setDraft((prev: any) => {
|
||||
const movements = [...prev.movements];
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= movements.length) return prev;
|
||||
[movements[index], movements[targetIndex]] = [movements[targetIndex], movements[index]];
|
||||
return { ...prev, movements };
|
||||
});
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
// Switch to YAML editing mode
|
||||
const switchToYaml = () => {
|
||||
const text = stringify(draft, { lineWidth: 120 });
|
||||
setYamlText(text);
|
||||
setYamlError(null);
|
||||
setEditMode('yaml');
|
||||
};
|
||||
|
||||
// Switch to visual editing mode
|
||||
const switchToVisual = () => {
|
||||
try {
|
||||
const parsed = parse(yamlText);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
setYamlError('YAML のパースに失敗しました');
|
||||
return;
|
||||
}
|
||||
setDraft(parsed);
|
||||
setYamlError(null);
|
||||
setEditMode('visual');
|
||||
setIsDirty(true);
|
||||
} catch (e: any) {
|
||||
setYamlError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleYamlChange = (text: string) => {
|
||||
setYamlText(text);
|
||||
setYamlError(null);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (piece) {
|
||||
setDraft(structuredClone(piece));
|
||||
setIsDirty(false);
|
||||
setEditMode('visual');
|
||||
setYamlError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draft) return;
|
||||
|
||||
let saveData = draft;
|
||||
if (editMode === 'yaml') {
|
||||
try {
|
||||
saveData = parse(yamlText);
|
||||
if (!saveData || typeof saveData !== 'object') {
|
||||
showToast('エラー: YAML のパースに失敗しました', 3000);
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: YAML パースエラー — ${e.message}`, 3000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure name matches
|
||||
saveData.name = name;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePiece(name, saveData);
|
||||
await queryClient.invalidateQueries({ queryKey: ['piece', name] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setIsDirty(false);
|
||||
if (editMode === 'yaml') {
|
||||
setDraft(saveData);
|
||||
}
|
||||
showToast('保存しました');
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: ${e.message}`, 3000);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
|
||||
try {
|
||||
await deletePiece(name);
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: ${e.message}`, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-400">Loading...</div>;
|
||||
if (error) return <div className="text-sm text-red-500">Piece の読み込みに失敗しました</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
const movementNames = (draft.movements ?? []).map((m: any) => m.name ?? '');
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">{draft.name}</h2>
|
||||
{draft.description && (
|
||||
<p className="text-sm text-slate-500 mt-0.5 line-clamp-2">{String(draft.description).split('\n')[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex items-center gap-1 mb-4 bg-slate-100 rounded-lg p-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => editMode === 'yaml' ? switchToVisual() : undefined}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
editMode === 'visual'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editMode === 'visual' ? switchToYaml() : undefined}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
editMode === 'yaml'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
YAML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
{/* Meta form */}
|
||||
<div className="mb-8">
|
||||
<PieceMetaForm
|
||||
piece={draft}
|
||||
onChange={handleMetaChange}
|
||||
movementNames={movementNames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Movements */}
|
||||
<div className="mb-6">
|
||||
<MovementAccordion
|
||||
movements={draft.movements ?? []}
|
||||
onChange={handleMovementChange}
|
||||
onAdd={handleAddMovement}
|
||||
onRemove={handleRemoveMovement}
|
||||
onMove={handleMoveMovement}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* YAML editor */
|
||||
<div className="mb-6">
|
||||
{yamlError && (
|
||||
<div className="mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-600">
|
||||
{yamlError}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={yamlText}
|
||||
onChange={(e) => handleYamlChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono bg-slate-50 leading-relaxed resize-y"
|
||||
style={{ minHeight: '500px', tabSize: 2 }}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
YAML を直接編集できます。Visual モードに切り替えると自動でパースされます。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
|
||||
{toast && (
|
||||
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!isDirty}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-4 py-2 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
export interface PieceMetaFormProps {
|
||||
piece: any;
|
||||
onChange: (field: string, value: any) => void;
|
||||
movementNames: string[];
|
||||
}
|
||||
|
||||
export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormProps) {
|
||||
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={piece.name ?? ''}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg bg-slate-50 text-slate-500 outline-none cursor-not-allowed"
|
||||
/>
|
||||
<HelpText>英小文字・数字・ハイフンのみ使用可能</HelpText>
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={piece.description ?? ''}
|
||||
onChange={(e) => onChange('description', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* max_movements */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">max_movements</label>
|
||||
<input
|
||||
type="number"
|
||||
value={piece.max_movements ?? 10}
|
||||
onChange={(e) => onChange('max_movements', parseInt(e.target.value, 10) || 0)}
|
||||
min={1}
|
||||
className="w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
<HelpText>1 ジョブで実行できる movement の最大回数。ループ防止のため</HelpText>
|
||||
</div>
|
||||
|
||||
{/* initial_movement */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">initial_movement</label>
|
||||
<select
|
||||
value={piece.initial_movement ?? ''}
|
||||
onChange={(e) => onChange('initial_movement', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
{movementNames.length === 0 && <option value="">--</option>}
|
||||
{movementNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<HelpText>ジョブ開始時に最初に実行される movement です</HelpText>
|
||||
</div>
|
||||
|
||||
{/* triggers.keywords */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">triggers.keywords</label>
|
||||
<input
|
||||
type="text"
|
||||
value={triggersText}
|
||||
onChange={(e) => {
|
||||
const keywords = e.target.value
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
onChange('triggers', { ...piece.triggers, keywords });
|
||||
}}
|
||||
placeholder="keyword1, keyword2, ..."
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
<HelpText>タスク本文にこれらのキーワードが含まれると、この piece が自動選択されます</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchMyOrgs, Visibility } from '../../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
export function PreferencesForm({ user }: { user: { defaultVisibility: Visibility; defaultVisibilityOrgId: string | null } }) {
|
||||
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs });
|
||||
const qc = useQueryClient();
|
||||
const [vis, setVis] = useState<Visibility>(user.defaultVisibility);
|
||||
const [orgId, setOrgId] = useState<string | null>(user.defaultVisibilityOrgId);
|
||||
useEffect(() => { setVis(user.defaultVisibility); setOrgId(user.defaultVisibilityOrgId); }, [user]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch('/api/users/me/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ defaultVisibility: vis, defaultVisibilityOrgId: vis === 'org' ? orgId : null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['auth', 'me'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">新規タスクのデフォルト公開範囲</h3>
|
||||
<div className="mt-2 flex gap-3 text-[13px]">
|
||||
<label><input type="radio" checked={vis === 'private'} onChange={() => setVis('private')} /> 🔒 非公開</label>
|
||||
<label><input type="radio" checked={vis === 'org'} onChange={() => setVis('org')} disabled={orgs.length === 0} /> 🏢 組織</label>
|
||||
<label><input type="radio" checked={vis === 'public'} onChange={() => setVis('public')} /> 🌐 公開</label>
|
||||
</div>
|
||||
<HelpText>
|
||||
🔒 非公開: 自分のみ閲覧可能 / 🏢 組織: 同じ Gitea org のメンバーが閲覧可能 / 🌐 公開: ログイン中の全ユーザーが閲覧可能
|
||||
</HelpText>
|
||||
{vis === 'org' && (
|
||||
<select value={orgId ?? ''} onChange={e => setOrgId(e.target.value)} className="mt-2 px-2 py-1 border rounded text-[13px]">
|
||||
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">所属している Gitea 組織</h3>
|
||||
<ul className="mt-2 text-[13px] text-slate-700 list-disc pl-5">
|
||||
{orgs.map(o => <li key={o.orgId}>{o.orgName}</li>)}
|
||||
{orgs.length === 0 && <li className="text-slate-400">(なし — Gitea でログインすると表示されます)</li>}
|
||||
</ul>
|
||||
<p className="mt-2 text-2xs text-slate-500">最新状態に更新するには、一度ログアウトして再ログインしてください。</p>
|
||||
</section>
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
className="px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px]"
|
||||
>
|
||||
{save.isPending ? '保存中…' : '設定を保存'}
|
||||
</button>
|
||||
{save.isError && <div className="text-red-600 text-xs">{String(save.error)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Reflection (Hermes mode) settings.
|
||||
*
|
||||
* Toggle + caps for the per-job reflection loop that auto-updates a user's
|
||||
* persistent memory. See `src/engine/reflection/` and the design doc at
|
||||
* docs/superpowers/specs/2026-05-11-self-improving-memory-design.md.
|
||||
*
|
||||
* For Reflection to actually run, a worker with `roles: [reflection]` must
|
||||
* exist in the LLM Workers tab. Otherwise (with the default `worker_required`)
|
||||
* jobs are silently skipped.
|
||||
*/
|
||||
export function ReflectionForm({ config, onChange }: SectionFormProps) {
|
||||
const reflection = config.reflection ?? {};
|
||||
// Step 7 (design 2026-05-21): read v2 `llm.workers`. The v2 API contract
|
||||
// already strips the legacy `provider` block from GET /api/config, so
|
||||
// falling back to it would always be empty. Kept as a defensive `?? []`
|
||||
// for the brief window where draft state may still be undefined.
|
||||
const workers = config.llm?.workers ?? [];
|
||||
const hasReflectionWorker = workers.some(
|
||||
(w: { roles?: string[] }) => Array.isArray(w.roles) && w.roles.includes('reflection'),
|
||||
);
|
||||
const enabled = reflection.enabled === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Reflection (Hermes mode)</h2>
|
||||
|
||||
<p className="text-xs text-slate-600 leading-relaxed">
|
||||
通常ジョブが完了するたびに LLM がそのジョブから学んだ教訓を抽出し、ユーザーの memory
|
||||
(<code className="font-mono text-2xs">data/users/{'{userId}'}/memory/</code>)
|
||||
と必要に応じて custom piece を自動更新します。全変更は snapshot として保存され、
|
||||
Memory & Learning タブから revert 可能です。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => onChange('reflection.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-semibold">Reflection を有効化(自動適用)</span>
|
||||
</label>
|
||||
<HelpText>
|
||||
ON にすると、エージェントジョブが終わるたびに reflection ジョブが裏で走り、memory
|
||||
を自動で書き換えます。デフォルト: 無効。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
{enabled && !hasReflectionWorker && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<div className="font-semibold mb-1">⚠ Reflection worker が未設定です</div>
|
||||
<div>
|
||||
Reflection を有効化しても、<code className="font-mono">roles</code> に
|
||||
<code className="font-mono">reflection</code> を含む worker が無いとジョブは
|
||||
enqueue されません。<strong>LLM → Workers</strong> タブで以下のような worker
|
||||
を追加してください:
|
||||
</div>
|
||||
<pre className="mt-2 text-2xs font-mono bg-white border border-amber-200 rounded p-2 overflow-auto">{`id: reflection-1
|
||||
connection_type: direct
|
||||
endpoint: http://localhost:11434/v1
|
||||
model: qwen2.5:3b # cheap モデル推奨
|
||||
roles: [reflection]
|
||||
max_concurrency: 1`}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reflection.workerRequired !== false}
|
||||
onChange={e => onChange('reflection.workerRequired', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
専用 reflection worker を必須にする
|
||||
</label>
|
||||
<HelpText>
|
||||
ON: <code className="font-mono">roles: [reflection]</code> を持つ worker が無い場合、
|
||||
reflection ジョブを enqueue せずスキップします (デフォルト)。OFF にすると enabled
|
||||
のみで他 worker に拾われる可能性あり。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Caps</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max memory changes per job</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.maxMemoryChangesPerJob ?? 3}
|
||||
onChange={v => onChange('reflection.maxMemoryChangesPerJob', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ジョブの reflection で書き込める memory entry の上限。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max entry body bytes</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.maxEntryBodyBytes ?? 8192}
|
||||
onChange={v => onChange('reflection.maxEntryBodyBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>memory entry body の最大バイト数。これを超えると semantic validator が reject。デフォルト: 8192</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Piece edit cooldown (hours)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.pieceEditCooldownHours ?? 24}
|
||||
onChange={v => onChange('reflection.pieceEditCooldownHours', Number(v))}
|
||||
/>
|
||||
<HelpText>同じ piece への連続編集を抑制する cooldown。デフォルト: 24 (24h 以内に 2 回編集されたら 3 回目以降はスキップ)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Activity log max bytes</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.activityLogMaxBytes ?? 4096}
|
||||
onChange={v => onChange('reflection.activityLogMaxBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>reflection LLM に渡す activity log の圧縮上限。デフォルト: 4096</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Budget</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Per-user daily budget (tokens)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.perUserDailyBudgetTokens ?? 200000}
|
||||
onChange={v => onChange('reflection.perUserDailyBudgetTokens', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ユーザーが 1 日に reflection で消費できる token 合計。超えた以降の reflection は enqueue されません。デフォルト: 200000</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Snapshot & Retention</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Snapshot retention (days)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.snapshotRetentionDays ?? 90}
|
||||
onChange={v => onChange('reflection.snapshotRetentionDays', Number(v))}
|
||||
/>
|
||||
<HelpText>reflection-history snapshot の保持日数。デフォルト: 90</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Snapshot max bytes per user</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.snapshotMaxBytesPerUser ?? 100 * 1024 * 1024}
|
||||
onChange={v => onChange('reflection.snapshotMaxBytesPerUser', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ユーザーあたりの snapshot ディレクトリ合計サイズ上限 (bytes)。超えると古い順に削除。デフォルト: 100 MiB</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Snapshot max bytes per entry</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.snapshotMaxBytesPerEntry ?? 1 * 1024 * 1024}
|
||||
onChange={v => onChange('reflection.snapshotMaxBytesPerEntry', Number(v))}
|
||||
/>
|
||||
<HelpText>1 snapshot エントリの最大サイズ (bytes)。デフォルト: 1 MiB</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reflection.storeLlmRaw === true}
|
||||
onChange={e => onChange('reflection.storeLlmRaw', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
スナップショットに LLM の生レスポンスを保存する
|
||||
</label>
|
||||
<HelpText>
|
||||
ON にすると <code className="font-mono">llm-raw.json</code> を snapshot に含めます。
|
||||
デバッグ用途、デフォルトは OFF (ディスク節約)。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Monitoring</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Abstain rate floor</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.abstainRateFloor ?? 0.3}
|
||||
onChange={v => onChange('reflection.abstainRateFloor', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>
|
||||
abstain (学ぶことなし) 率がこれを下回ると過剰学習サインとして警告 (運用シグナル)。
|
||||
デフォルト: 0.3。この値を下回った場合はシステムログに warn が出ます。
|
||||
反映率が高すぎる場合は max_memory_changes_per_job を下げることを検討してください。
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
const SPECIAL_TARGETS = ['COMPLETE', 'ASK', 'ABORT', 'WAIT_SUBTASKS'];
|
||||
|
||||
export interface RulesTableProps {
|
||||
rules: Array<{ condition: string; next: string }>;
|
||||
movementNames: string[];
|
||||
onChange: (rules: Array<{ condition: string; next: string }>) => void;
|
||||
}
|
||||
|
||||
export function RulesTable({ rules, movementNames, onChange }: RulesTableProps) {
|
||||
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
|
||||
|
||||
const updateRule = (index: number, field: 'condition' | 'next', value: string) => {
|
||||
const updated = rules.map((r, i) => (i === index ? { ...r, [field]: value } : r));
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
onChange([...rules, { condition: '', next: movementNames[0] ?? 'COMPLETE' }]);
|
||||
};
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
onChange(rules.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">rules</label>
|
||||
{rules.length > 0 && (
|
||||
<table className="w-full text-sm mb-2">
|
||||
<thead>
|
||||
<tr className="text-xs text-slate-500">
|
||||
<th className="text-left font-medium pb-1 pr-2">condition</th>
|
||||
<th className="text-left font-medium pb-1 pr-2 w-44">next</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule, i) => (
|
||||
<tr key={i}>
|
||||
<td className="pr-2 pb-1">
|
||||
<input
|
||||
type="text"
|
||||
value={rule.condition}
|
||||
onChange={(e) => updateRule(i, 'condition', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
placeholder="条件..."
|
||||
/>
|
||||
</td>
|
||||
<td className="pr-2 pb-1">
|
||||
<select
|
||||
value={rule.next}
|
||||
onChange={(e) => updateRule(i, 'next', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
{nextOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(i)}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
<HelpText>LLM が transition ツールで遷移先を選ぶ際の条件です</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
const safety = config.safety ?? {};
|
||||
const historySummarization = safety.historySummarization ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Safety</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Iterations</FieldLabel>
|
||||
<FieldInput type="number" value={safety.maxIterations ?? 200}
|
||||
onChange={v => onChange('safety.maxIterations', Number(v))} />
|
||||
<HelpText>1 movement あたりの最大イテレーション回数。デフォルト: 200</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Revisits</FieldLabel>
|
||||
<FieldInput type="number" value={safety.maxRevisits ?? 3}
|
||||
onChange={v => onChange('safety.maxRevisits', Number(v))} />
|
||||
<HelpText>同一 movement への再訪問上限(ループ検出)。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Prompt Guard Ratio</FieldLabel>
|
||||
<FieldInput type="number" value={safety.promptGuardRatio ?? 0.8}
|
||||
onChange={v => onChange('safety.promptGuardRatio', v ? Number(v) : undefined)} />
|
||||
<HelpText>送信前に prompt がコンテキスト上限の何割を占めたら自動圧縮するか(0.5〜0.95、デフォルト: 0.8)</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">History Summarization</h3>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={historySummarization.enabled !== false}
|
||||
onChange={e => onChange('safety.historySummarization.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
履歴の自動要約を有効化
|
||||
</label>
|
||||
<HelpText>古い会話履歴を自動で要約して context を節約。デフォルト: 有効</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Tail Turns</FieldLabel>
|
||||
<FieldInput type="number" value={historySummarization.tailTurns ?? 2}
|
||||
onChange={v => onChange('safety.historySummarization.tailTurns', Number(v))} />
|
||||
<HelpText>常に保持する直近の assistant+tool ターン数。デフォルト: 2</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Preserve Recent Budget</FieldLabel>
|
||||
<FieldInput type="number" value={historySummarization.preserveRecentBudget ?? 8000}
|
||||
onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} />
|
||||
<HelpText>要約せず温存する直近メッセージのトークン予算。デフォルト: 8000</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function SearchFilterForm({ config, onChange }: SectionFormProps) {
|
||||
const sf = config.searchFilter ?? {};
|
||||
const autoBlock = sf.autoBlock ?? {};
|
||||
|
||||
const toggleAutoBlock = (key: string, value: boolean) => {
|
||||
onChange(`searchFilter.autoBlock.${key}`, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Search Filter</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Blocked Patterns (ブロックパターン)</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={sf.blockedPatterns ?? []}
|
||||
onChange={v => onChange('searchFilter.blockedPatterns', v)}
|
||||
placeholder="regex pattern"
|
||||
/>
|
||||
<HelpText>WebSearch クエリからフィルタするパターン(正規表現)。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Auto Block (自動ブロック)</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{([
|
||||
['privateIp', 'プライベートIP', autoBlock.privateIp],
|
||||
['internalDomain', '内部ドメイン', autoBlock.internalDomain],
|
||||
['email', 'メールアドレス', autoBlock.email],
|
||||
['phone', '電話番号', autoBlock.phone],
|
||||
] as const).map(([key, label, checked]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={e => toggleAutoBlock(key, e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>検索クエリに含まれる機密情報を自動でブロック。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
parseSecretValue,
|
||||
serializeSecretValue,
|
||||
type SecretFieldValue,
|
||||
} from '../../api';
|
||||
|
||||
interface SecretInputProps {
|
||||
/**
|
||||
* Current stored value (as fetched from `/api/config`). May be the
|
||||
* masked sentinel `'********'`, an `${ENV_REF}` pattern, a literal
|
||||
* plaintext (rare), or empty. The component parses it on the fly so
|
||||
* the parent form can keep using `config.llm.workers[i].apiKey` as a
|
||||
* plain string.
|
||||
*/
|
||||
rawValue: string | null | undefined;
|
||||
/**
|
||||
* Called when the user changes the stored form. Receives the
|
||||
* already-serialized string the parent should write back into the
|
||||
* draft config. Phase 1 keeps this string-shaped for backwards
|
||||
* compatibility with the existing `apiKey: string` config field.
|
||||
*/
|
||||
onChange: (serialized: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4-state secret editor (Phase 1).
|
||||
*
|
||||
* The control surface exposes three actions:
|
||||
* - Edit literal: user types a plaintext secret
|
||||
* - Use env ref: user types an env var name; saved as `${NAME}`
|
||||
* - Clear: erases the stored secret (saved as empty string)
|
||||
* The fourth state — `unchanged` — is what the component reports when
|
||||
* the displayed value is still the server mask and the user has not
|
||||
* touched anything.
|
||||
*
|
||||
* The form payload is currently a plain string so the existing server-
|
||||
* side mask preservation (see `src/config-manager.ts`) continues to
|
||||
* work without API changes. The 4-state contract lives in the UI today;
|
||||
* Phase 2 will lift it onto the wire.
|
||||
*/
|
||||
export function SecretInput({ rawValue, onChange, placeholder }: SecretInputProps) {
|
||||
const initial = parseSecretValue(rawValue);
|
||||
// Local UI state for the editor mode. Initialized from the stored
|
||||
// value so reopening the form shows the right shape.
|
||||
const [mode, setMode] = useState<SecretFieldValue['type']>(initial.type);
|
||||
// For literal / env_ref modes we keep a local draft so the user can
|
||||
// type freely. We push to the parent on each keystroke.
|
||||
const [literalDraft, setLiteralDraft] = useState(
|
||||
initial.type === 'literal' ? initial.value : '',
|
||||
);
|
||||
const [envDraft, setEnvDraft] = useState(
|
||||
initial.type === 'env_ref' ? initial.env_name : '',
|
||||
);
|
||||
|
||||
const emit = (next: SecretFieldValue) => {
|
||||
onChange(serializeSecretValue(next));
|
||||
};
|
||||
|
||||
const setLiteralMode = () => {
|
||||
setMode('literal');
|
||||
// Don't emit yet — wait for the user to type. Pre-fill the parent
|
||||
// with an empty literal so save reflects "literal=empty" rather than
|
||||
// the previous masked value.
|
||||
emit({ type: 'literal', value: literalDraft });
|
||||
};
|
||||
|
||||
const setEnvMode = () => {
|
||||
setMode('env_ref');
|
||||
emit({ type: 'env_ref', env_name: envDraft });
|
||||
};
|
||||
|
||||
const setClearedMode = () => {
|
||||
setMode('cleared');
|
||||
emit({ type: 'cleared' });
|
||||
};
|
||||
|
||||
const setUnchangedMode = () => {
|
||||
setMode('unchanged');
|
||||
emit({ type: 'unchanged' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{mode === 'unchanged' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value="••••••••"
|
||||
readOnly
|
||||
className="flex-1 h-8 px-2.5 text-[13px] border border-hairline rounded-md bg-slate-50 text-slate-500"
|
||||
/>
|
||||
<span className="inline-flex items-center px-2 h-6 text-2xs rounded bg-slate-100 text-slate-600">
|
||||
masked
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'literal' && (
|
||||
<input
|
||||
type="password"
|
||||
value={literalDraft}
|
||||
onChange={e => {
|
||||
setLiteralDraft(e.target.value);
|
||||
emit({ type: 'literal', value: e.target.value });
|
||||
}}
|
||||
placeholder={placeholder ?? 'sk-...'}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'env_ref' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2 h-8 text-2xs rounded bg-slate-100 text-slate-600 font-mono">
|
||||
${'{'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={envDraft}
|
||||
onChange={e => {
|
||||
const next = e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
||||
setEnvDraft(next);
|
||||
emit({ type: 'env_ref', env_name: next });
|
||||
}}
|
||||
placeholder="ENV_VAR_NAME"
|
||||
className="flex-1 h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white font-mono"
|
||||
/>
|
||||
<span className="inline-flex items-center px-2 h-8 text-2xs rounded bg-slate-100 text-slate-600 font-mono">
|
||||
{'}'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'cleared' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="(cleared)"
|
||||
readOnly
|
||||
className="flex-1 h-8 px-2.5 text-[13px] border border-hairline rounded-md bg-slate-50 text-slate-400 italic"
|
||||
/>
|
||||
<span className="inline-flex items-center px-2 h-6 text-2xs rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
will be cleared
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1 text-2xs">
|
||||
{/* Show "Keep" only when the server actually has a masked value
|
||||
to keep; otherwise the option is meaningless. */}
|
||||
{initial.type === 'unchanged' && mode !== 'unchanged' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setUnchangedMode}
|
||||
className="px-2 py-0.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Keep current
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'literal' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setLiteralMode}
|
||||
className="px-2 py-0.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Edit literal
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'env_ref' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setEnvMode}
|
||||
className="px-2 py-0.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Use env var
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'cleared' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setClearedMode}
|
||||
className="px-2 py-0.5 rounded border border-amber-200 text-amber-700 hover:bg-amber-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
interface SettingsSidebarProps {
|
||||
activeSection?: string;
|
||||
onSelectSection: (section: string) => void;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings navigation, restructured to match the
|
||||
* 2026-05-21-settings-ui-and-config-restructure-design.md (Step 3).
|
||||
*
|
||||
* The form components themselves are intentionally not rewritten in this
|
||||
* step — Provider/Workers/etc keep reading `provider.*` for now and will
|
||||
* show as partially empty against the v2 API. Steps 7-9 swap those forms
|
||||
* to read the new `llm.*` / `gateway.*` keys.
|
||||
*
|
||||
* Old sidebar ids (provider, workspace, tools, browser-settings,
|
||||
* search-filter) still parse via `urlState.ts` and are redirected to
|
||||
* their new homes by `LEGACY_SECTION_REDIRECT` in this file. This keeps
|
||||
* old bookmarks/links working through the transition.
|
||||
*/
|
||||
const CONFIG_GROUPS = [
|
||||
{
|
||||
label: 'User',
|
||||
sections: [
|
||||
{ id: 'preferences', label: 'Preferences' },
|
||||
{ id: 'notifications', label: '🔔 Notifications' },
|
||||
{ id: 'memory-learning', label: '🧠 Memory & Learning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'branding', label: 'Branding' },
|
||||
{ id: 'paths-storage', label: 'Paths & Storage' },
|
||||
{ id: 'execution', label: 'Execution' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'LLM',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'llm-workers', label: 'Workers' },
|
||||
// Step 8: Gateway Keys absorbed into Gateway Server as the
|
||||
// "Virtual Keys" section. Bookmarks to `gateway-keys` are
|
||||
// redirected via LEGACY_SECTION_REDIRECT below.
|
||||
{ id: 'gateway-server', label: 'Gateway Server' },
|
||||
{ id: 'llm-metrics', label: 'Metrics' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Agent Runtime',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'ask-subtasks', label: 'Ask / Subtasks' },
|
||||
{ id: 'context', label: 'Context' },
|
||||
{ id: 'safety', label: 'Safety' },
|
||||
{ id: 'reflection', label: 'Reflection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'tools-web', label: 'Web & Search' },
|
||||
{ id: 'tools-browser', label: 'Browser Runtime' },
|
||||
{ id: 'tools-media', label: 'Media & Documents' },
|
||||
{ id: 'tools-external', label: 'External Services' },
|
||||
{ id: 'tools-legacy-knowledge', label: 'Legacy Knowledge' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MCP & Connections',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'mcp', label: 'MCP Runtime' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SSH',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'ssh', label: 'Admin SSH' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Old sidebar id → new id mapping. Used by `SettingsPage` to upgrade
|
||||
* URLs / bookmarks left over from the pre-Step-3 sidebar layout. Keep
|
||||
* each entry until the underlying old id is fully removed from
|
||||
* `SETTINGS_SECTIONS` in `urlState.ts`.
|
||||
*
|
||||
* `tools` (the catch-all tab) maps to the first new Tools sub-section.
|
||||
* Power users coming in via that old URL should land on something
|
||||
* visible rather than a blank screen.
|
||||
*/
|
||||
export const LEGACY_SECTION_REDIRECT: Record<string, string> = {
|
||||
provider: 'llm-workers',
|
||||
workspace: 'paths-storage',
|
||||
tools: 'tools-web',
|
||||
'browser-settings': 'tools-browser',
|
||||
'search-filter': 'tools-web',
|
||||
// browser-sessions never had a Settings page in the new layout —
|
||||
// it lives in User Folder. Keep mapping so an old URL still goes
|
||||
// somewhere sensible.
|
||||
'browser-sessions': 'preferences',
|
||||
// Step 8: Gateway Keys folded into Gateway Server. Bookmarks land
|
||||
// on the parent form which now hosts the Virtual Keys section.
|
||||
'gateway-keys': 'gateway-server',
|
||||
skills: 'preferences',
|
||||
};
|
||||
|
||||
/** Sections that any authenticated user (not just admin) can access. */
|
||||
export const USER_SECTIONS: string[] = CONFIG_GROUPS
|
||||
.filter(g => !('adminOnly' in g) || !g.adminOnly)
|
||||
.flatMap(g => g.sections.map(s => s.id));
|
||||
|
||||
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
|
||||
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto border-r border-hairline bg-white p-3">
|
||||
{visibleGroups.map(group => (
|
||||
<div key={group.label} className="mb-3">
|
||||
<div className="section-label px-2 py-1">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.sections.map(s => (
|
||||
<button key={s.id} onClick={() => onSelectSection(s.id)}
|
||||
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
|
||||
activeSection === s.id
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* SkillsForm.tsx — Settings > Skills tab
|
||||
*
|
||||
* Two-column list + detail layout for browsing, creating, editing, and
|
||||
* deleting agent skills. Supports installing skills from a URL.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthState } from '../../App';
|
||||
import {
|
||||
fetchSkills,
|
||||
fetchSkillDetail,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
installSkillFromUrl,
|
||||
type SkillSummary,
|
||||
type SkillDetail,
|
||||
} from '../../api';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
function SourceBadge({ source }: { source: 'system' | 'user' }) {
|
||||
return source === 'system' ? (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-slate-100 text-slate-600">
|
||||
<span aria-label="locked">🔒</span> system
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-blue-50 text-blue-700">
|
||||
user
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
if (severity === 'high') {
|
||||
return <span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-100 text-red-700">HIGH</span>;
|
||||
}
|
||||
if (severity === 'medium') {
|
||||
return <span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-yellow-100 text-yellow-800">MEDIUM</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function SkillsForm() {
|
||||
const qc = useQueryClient();
|
||||
const auth = useAuthState();
|
||||
const isAdmin = auth.mode === 'authenticated' && auth.user.role === 'admin';
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [newMode, setNewMode] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newContent, setNewContent] = useState('');
|
||||
const [newScope, setNewScope] = useState<'user' | 'system'>('user');
|
||||
const [installUrl, setInstallUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────
|
||||
|
||||
const skillsQuery = useQuery<SkillSummary[]>({
|
||||
queryKey: ['skills'],
|
||||
queryFn: () => fetchSkills(),
|
||||
});
|
||||
|
||||
const detailQuery = useQuery<SkillDetail>({
|
||||
queryKey: ['skill-detail', selected],
|
||||
queryFn: () => fetchSkillDetail(selected!),
|
||||
enabled: !!selected && !newMode,
|
||||
});
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createSkill(newName.trim(), newContent, newScope),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['skills'] });
|
||||
setSelected(newName.trim());
|
||||
setNewMode(false);
|
||||
setNewName('');
|
||||
setNewContent('');
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ name, content, scope }: { name: string; content: string; scope: string }) =>
|
||||
updateSkill(name, content, scope),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['skills'] });
|
||||
qc.invalidateQueries({ queryKey: ['skill-detail', selected] });
|
||||
setEditMode(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: ({ name, scope }: { name: string; scope: string }) => deleteSkill(name, scope),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['skills'] });
|
||||
setSelected(null);
|
||||
setEditMode(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const installMut = useMutation({
|
||||
mutationFn: () => installSkillFromUrl(installUrl.trim(), 'user'),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['skills'] });
|
||||
setInstallUrl('');
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const skills = skillsQuery.data ?? [];
|
||||
|
||||
const handleSelect = (name: string) => {
|
||||
setSelected(name);
|
||||
setNewMode(false);
|
||||
setEditMode(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setNewMode(true);
|
||||
setSelected(null);
|
||||
setEditMode(false);
|
||||
setNewName('');
|
||||
setNewContent('');
|
||||
setNewScope('user');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (detailQuery.data) {
|
||||
setEditContent(detailQuery.data.content);
|
||||
setEditMode(true);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!selected || !detailQuery.data) return;
|
||||
updateMut.mutate({ name: selected, content: editContent, scope: detailQuery.data.source });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selected || !detailQuery.data) return;
|
||||
if (!confirm(`Delete skill "${selected}"?`)) return;
|
||||
deleteMut.mutate({ name: selected, scope: detailQuery.data.source });
|
||||
};
|
||||
|
||||
const canEdit = (skill: SkillSummary | SkillDetail | undefined) => {
|
||||
if (!skill) return false;
|
||||
return skill.source === 'user' || isAdmin;
|
||||
};
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-800 mb-3">Skills</h2>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Skills are agent reference guides and knowledge bases. They provide context and instructions to the agent during task execution.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 px-3 py-2 rounded bg-red-50 border border-red-200 text-xs text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4" style={{ minHeight: '500px' }}>
|
||||
{/* ── Left panel: list ────────────────────────────────────────── */}
|
||||
<div className="w-1/3 border border-hairline rounded-lg p-3 overflow-y-auto flex flex-col gap-2">
|
||||
{/* Install from URL */}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Install from URL..."
|
||||
value={installUrl}
|
||||
onChange={e => setInstallUrl(e.target.value)}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-hairline rounded bg-white text-slate-700 placeholder:text-slate-400"
|
||||
/>
|
||||
<button
|
||||
onClick={() => installMut.mutate()}
|
||||
disabled={!installUrl.trim() || installMut.isPending}
|
||||
className="px-2 py-1 text-xs font-semibold bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{installMut.isPending ? '...' : 'Install'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skill list */}
|
||||
{skillsQuery.isLoading ? (
|
||||
<div className="text-xs text-slate-400 py-4 text-center">Loading...</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="text-xs text-slate-400 py-4 text-center">No skills installed</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{skills.map(s => (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => handleSelect(s.name)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${
|
||||
selected === s.name && !newMode
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{s.name}</span>
|
||||
<SourceBadge source={s.source} />
|
||||
</div>
|
||||
{s.description && (
|
||||
<div className="text-[10px] text-slate-500 truncate mt-0.5">{s.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New skill button */}
|
||||
<button
|
||||
onClick={handleNew}
|
||||
className="mt-auto px-2 py-1.5 text-xs font-semibold text-accent border border-accent/30 rounded hover:bg-accent-soft transition-colors"
|
||||
>
|
||||
+ New Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Right panel: detail / new / empty ──────────────────────── */}
|
||||
<div className="flex-1 border border-hairline rounded-lg p-4 overflow-y-auto">
|
||||
{newMode ? (
|
||||
/* ── New skill form ─────────────────────────────────────── */
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Create New Skill</h3>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-slate-600 font-medium">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="my-skill-name"
|
||||
className="mt-1 block w-full px-2 py-1.5 text-xs border border-hairline rounded bg-white text-slate-700 placeholder:text-slate-400"
|
||||
/>
|
||||
{newName && !NAME_RE.test(newName) && (
|
||||
<span className="text-[10px] text-red-500 mt-0.5">Lowercase letters, numbers, hyphens, underscores only</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-slate-600 font-medium">Content</span>
|
||||
<textarea
|
||||
value={newContent}
|
||||
onChange={e => setNewContent(e.target.value)}
|
||||
rows={14}
|
||||
className="mt-1 block w-full px-2 py-1.5 text-xs font-mono border border-hairline rounded bg-white text-slate-700 resize-y"
|
||||
placeholder="# My Skill Instructions for the agent..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend className="text-xs text-slate-600 font-medium mb-1">Scope</legend>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-700">
|
||||
<input type="radio" name="scope" value="user" checked={newScope === 'user'} onChange={() => setNewScope('user')} />
|
||||
Personal
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-700">
|
||||
<input type="radio" name="scope" value="system" checked={newScope === 'system'} onChange={() => setNewScope('system')} />
|
||||
System
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!newName.trim() || !NAME_RE.test(newName) || !newContent.trim() || createMut.isPending}
|
||||
className="px-3 py-1.5 text-xs font-semibold bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{createMut.isPending ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setNewMode(false); setError(null); }}
|
||||
className="px-3 py-1.5 text-xs text-slate-700 border border-hairline rounded hover:bg-surface transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : selected && detailQuery.data ? (
|
||||
/* ── Skill detail ──────────────────────────────────────── */
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-800">{detailQuery.data.name}</h3>
|
||||
<SourceBadge source={detailQuery.data.source} />
|
||||
{detailQuery.data.maxSeverity !== 'none' && (
|
||||
<SeverityBadge severity={detailQuery.data.maxSeverity} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailQuery.data.description && (
|
||||
<p className="text-xs text-slate-600">{detailQuery.data.description}</p>
|
||||
)}
|
||||
|
||||
{detailQuery.data.triggers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{detailQuery.data.triggers.map(t => (
|
||||
<span key={t} className="px-1.5 py-0.5 rounded text-[10px] bg-slate-100 text-slate-600 font-mono">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Findings */}
|
||||
{detailQuery.data.findings.length > 0 && (
|
||||
<div className="border border-yellow-200 bg-yellow-50 rounded p-2 flex flex-col gap-1">
|
||||
<div className="text-xs font-semibold text-yellow-800">Security Findings</div>
|
||||
{detailQuery.data.findings.map((f, i) => (
|
||||
<div key={i} className="text-[10px] text-yellow-700 font-mono">
|
||||
<SeverityBadge severity={f.severity} />{' '}
|
||||
L{f.line}: {f.pattern} — <code>{f.match}</code>
|
||||
{f.file && <span className="text-slate-500"> ({f.file})</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{editMode ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
rows={18}
|
||||
className="block w-full px-2 py-1.5 text-xs font-mono border border-hairline rounded bg-white text-slate-700 resize-y"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={updateMut.isPending}
|
||||
className="px-3 py-1.5 text-xs font-semibold bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{updateMut.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditMode(false); setError(null); }}
|
||||
className="px-3 py-1.5 text-xs text-slate-700 border border-hairline rounded hover:bg-surface transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-slate-700 bg-surface/50 border border-hairline rounded p-3 max-h-[400px] overflow-y-auto">
|
||||
{detailQuery.data.content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Files (directory skills) */}
|
||||
{detailQuery.data.hasDir && detailQuery.data.files.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-slate-600 mb-1">Files</div>
|
||||
<ul className="list-disc list-inside text-xs text-slate-500 font-mono">
|
||||
{detailQuery.data.files.map(f => <li key={f}>{f}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{canEdit(detailQuery.data) && !editMode && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onClick={handleStartEdit}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-accent border border-accent/30 rounded hover:bg-accent-soft transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMut.isPending}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-red-600 border border-red-200 rounded hover:bg-red-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleteMut.isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : selected && detailQuery.isLoading ? (
|
||||
<div className="text-xs text-slate-400 py-8 text-center">Loading skill...</div>
|
||||
) : (
|
||||
/* ── Empty state ───────────────────────────────────────── */
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<div className="text-sm text-slate-400 mb-2">Skills are agent reference guides and knowledge bases.</div>
|
||||
<div className="text-xs text-slate-400">Select a skill from the list, or install one from a URL.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { SshAuditRow } from '../../lib/ssh-types';
|
||||
|
||||
interface Filters {
|
||||
action: string;
|
||||
ownerId: string;
|
||||
connectionId: string;
|
||||
outcome: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
async function fetchAudit(filters: Filters): Promise<SshAuditRow[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.action) params.set('action', filters.action);
|
||||
if (filters.ownerId) params.set('ownerId', filters.ownerId);
|
||||
if (filters.connectionId) params.set('connectionId', filters.connectionId);
|
||||
if (filters.outcome) params.set('outcome', filters.outcome);
|
||||
params.set('limit', String(filters.limit));
|
||||
const res = await fetch(`/api/ssh/admin/audit?${params.toString()}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { audit: SshAuditRow[] };
|
||||
return data.audit ?? [];
|
||||
}
|
||||
|
||||
const ACTION_HINTS = [
|
||||
'ssh.exec',
|
||||
'ssh.upload',
|
||||
'ssh.download',
|
||||
'ssh.connection.upsert',
|
||||
'ssh.connection.disable',
|
||||
'ssh.connection.enable',
|
||||
'ssh.connection.delete',
|
||||
'ssh.connection.host_key.tofu_record',
|
||||
'ssh.connection.host_key.verify',
|
||||
'ssh.connection.host_key.mismatch',
|
||||
'ssh.connection.host_key.replace',
|
||||
'ssh.grant.create',
|
||||
'ssh.grant.delete',
|
||||
'ssh.grant.use',
|
||||
'ssh.abuse.lock',
|
||||
'ssh.abuse.unlock_manual',
|
||||
'ssh.master_key.rotate.start',
|
||||
];
|
||||
|
||||
export function SshAuditLog() {
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
action: '',
|
||||
ownerId: '',
|
||||
connectionId: '',
|
||||
outcome: '',
|
||||
limit: 100,
|
||||
});
|
||||
const { data, isLoading, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['ssh', 'admin', 'audit', filters],
|
||||
queryFn: () => fetchAudit(filters),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
function update<K extends keyof Filters>(key: K, value: Filters[K]) {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">監査ログ</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-2xs">
|
||||
<label className="block">
|
||||
<div className="font-semibold text-slate-500 uppercase tracking-wide mb-0.5">Action</div>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.action}
|
||||
onChange={e => update('action', e.target.value)}
|
||||
list="ssh-audit-actions"
|
||||
placeholder="ssh.exec"
|
||||
className="w-full text-2xs px-2 py-1 border border-hairline rounded font-mono"
|
||||
/>
|
||||
<datalist id="ssh-audit-actions">
|
||||
{ACTION_HINTS.map(a => <option key={a} value={a} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="font-semibold text-slate-500 uppercase tracking-wide mb-0.5">Owner ID</div>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.ownerId}
|
||||
onChange={e => update('ownerId', e.target.value)}
|
||||
placeholder="user id"
|
||||
className="w-full text-2xs px-2 py-1 border border-hairline rounded font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="font-semibold text-slate-500 uppercase tracking-wide mb-0.5">Connection ID</div>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.connectionId}
|
||||
onChange={e => update('connectionId', e.target.value)}
|
||||
placeholder="conn id"
|
||||
className="w-full text-2xs px-2 py-1 border border-hairline rounded font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="font-semibold text-slate-500 uppercase tracking-wide mb-0.5">Outcome</div>
|
||||
<select
|
||||
value={filters.outcome}
|
||||
onChange={e => update('outcome', e.target.value)}
|
||||
className="w-full text-2xs px-2 py-1 border border-hairline rounded"
|
||||
>
|
||||
<option value="">(any)</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="success">success</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="denied">denied</option>
|
||||
<option value="aborted">aborted</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="px-2 h-6 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{isFetching ? '更新中…' : '再読み込み'}
|
||||
</button>
|
||||
<span className="text-2xs text-slate-500">{data?.length ?? 0} 件表示 (limit {filters.limit})</span>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-xs text-slate-400">Loading…</div>}
|
||||
{error && <div className="text-xs text-red-500">{String(error)}</div>}
|
||||
|
||||
<div className="overflow-x-auto rounded border border-hairline">
|
||||
<table className="w-full text-2xs">
|
||||
<thead className="bg-surface/60 border-b border-hairline">
|
||||
<tr className="text-left">
|
||||
<th className="px-2 py-1 font-semibold text-slate-700">Time</th>
|
||||
<th className="px-2 py-1 font-semibold text-slate-700">Action</th>
|
||||
<th className="px-2 py-1 font-semibold text-slate-700">Outcome</th>
|
||||
<th className="px-2 py-1 font-semibold text-slate-700">Actor</th>
|
||||
<th className="px-2 py-1 font-semibold text-slate-700">Connection</th>
|
||||
<th className="px-2 py-1 font-semibold text-slate-700">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-hairline bg-white">
|
||||
{(data ?? []).map(r => (
|
||||
<tr key={r.id} className="hover:bg-surface/40">
|
||||
<td className="px-2 py-1 font-mono text-slate-600 whitespace-nowrap">{r.startedAt}</td>
|
||||
<td className="px-2 py-1 font-mono text-slate-800 whitespace-nowrap">{r.action}</td>
|
||||
<td className="px-2 py-1">
|
||||
<OutcomeBadge outcome={r.outcome} />
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono text-slate-600 truncate max-w-[100px]">{r.actingUserId ?? '-'}</td>
|
||||
<td className="px-2 py-1 font-mono text-slate-600 truncate max-w-[100px]">{r.connectionId ?? '-'}</td>
|
||||
<td className="px-2 py-1 font-mono text-slate-700 truncate max-w-[280px]" title={r.detail ? JSON.stringify(r.detail) : ''}>
|
||||
{r.reason ? <span className="text-slate-500">{r.reason}</span> : null}
|
||||
{r.detail !== null && r.detail !== undefined ? (
|
||||
<span className="ml-1">{JSON.stringify(r.detail)}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(data ?? []).length === 0 && !isLoading && (
|
||||
<tr><td colSpan={6} className="px-2 py-4 text-center text-slate-400">該当する監査ログがありません</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
pending: 'bg-slate-100 text-slate-600',
|
||||
success: 'bg-emerald-50 text-emerald-700',
|
||||
failed: 'bg-red-50 text-red-700',
|
||||
denied: 'bg-amber-50 text-amber-700',
|
||||
aborted: 'bg-slate-100 text-slate-500',
|
||||
};
|
||||
const cls = colorMap[outcome] ?? 'bg-slate-100 text-slate-600';
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${cls}`}>
|
||||
{outcome}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* SSH global config editor (`ssh.*` + nested `ssh.console.*`).
|
||||
*
|
||||
* Edits the `ssh.*` block of config.yaml via the same draft / save-bar flow as
|
||||
* other ConfigFormInner sections. Admin sub-tools (global connections /
|
||||
* grants / rotation / audit) hit their own per-row endpoints and live in
|
||||
* sibling subtabs of `SshForm`.
|
||||
*/
|
||||
export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenByEnv }: SectionFormProps) {
|
||||
const ssh = config.ssh ?? {};
|
||||
const console_ = ssh.console ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ssh.enabled === true}
|
||||
onChange={e => onChange('ssh.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
SSH 機能を有効化する
|
||||
</label>
|
||||
<HelpText>
|
||||
OFF の間は SshExec / SshUpload / SshDownload / SshConsole* 全ツールが利用不可になり、
|
||||
関連 API も router に登録されない。<code className="font-mono">MCP_ENCRYPTION_KEY</code> 環境変数も
|
||||
別途必須 (鍵が無い場合は ON にしても subsystem は disabled で起動)。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ssh.allowPrivateAddresses === true}
|
||||
onChange={e => onChange('ssh.allowPrivateAddresses', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
プライベート / loopback アドレスへの接続を許可する
|
||||
</label>
|
||||
<HelpText>
|
||||
self-hosted / LAN 上のサーバーに繋ぐ場合に必要。OFF だと <code className="font-mono">10.x</code>,
|
||||
<code className="font-mono">192.168.x</code>, <code className="font-mono">127.0.0.1</code> 等の
|
||||
private アドレスへの接続が reject される。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ssh.adminBypassesGrants === true}
|
||||
onChange={e => onChange('ssh.adminBypassesGrants', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Admin は grant 無しでも接続を利用できる
|
||||
</label>
|
||||
<HelpText>
|
||||
ON: admin role の user は per-connection grant 無しでも全接続にアクセス可
|
||||
(監査ログには記録される)。OFF: admin もユーザーと同じく明示的 grant 必須。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
|
||||
Limits / Timeouts
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Call timeout (秒)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.callTimeoutSeconds ?? 30}
|
||||
onChange={v => onChange('ssh.callTimeoutSeconds', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
SshExec / SshUpload / SshDownload の wall-clock 上限 (TCP connect + auth + 実行を含む)。
|
||||
デフォルト 30。SshConsole* には適用されない (こちらは idle/duration cap 側で管理)。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max output bytes</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.maxOutputBytes ?? 32768}
|
||||
onChange={v => onChange('ssh.maxOutputBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
SshExec の stdout/stderr の合計バイト上限。超過分は truncate され
|
||||
<code className="font-mono">truncated_stdout: true</code> で返る。デフォルト 32768 (32 KiB)。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel>Max upload size (MB)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.maxUploadSizeMb ?? 100}
|
||||
onChange={v => onChange('ssh.maxUploadSizeMb', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Max download size (MB)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.maxDownloadSizeMb ?? 100}
|
||||
onChange={v => onChange('ssh.maxDownloadSizeMb', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpText>SshUpload / SshDownload のファイルサイズ上限。超過は転送前に reject。</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Audit retention (日)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.auditRetentionDays ?? 90}
|
||||
onChange={v => onChange('ssh.auditRetentionDays', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
<code className="font-mono">ssh_audit_log</code> テーブルの保持日数。Audit tab の
|
||||
"Prune" ボタンでこの値より古い行を削除できる。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
|
||||
Abuse detection
|
||||
</h3>
|
||||
<HelpText>
|
||||
ホスト鍵不一致 / 認証失敗 / コマンド失敗が短時間に集中したら接続を一時ロックする。
|
||||
ロック中は同接続が <code className="font-mono">abuse_locked</code> エラーで reject される。
|
||||
</HelpText>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<FieldLabel>Window (分)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.abuseWindowMinutes ?? 10}
|
||||
onChange={v => onChange('ssh.abuseWindowMinutes', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Failure threshold</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.abuseFailureThreshold ?? 5}
|
||||
onChange={v => onChange('ssh.abuseFailureThreshold', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Lock duration (分)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.abuseLockMinutes ?? 30}
|
||||
onChange={v => onChange('ssh.abuseLockMinutes', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
|
||||
Interactive Console (SSH タブ / SshConsole* tools)
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={console_.enabled === true}
|
||||
onChange={e => onChange('ssh.console.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Console 機能を有効化する
|
||||
</label>
|
||||
<HelpText>
|
||||
OFF だと SshConsole* tools と <code className="font-mono">SSH</code> タブが無効。
|
||||
上の "SSH 機能を有効化する" と <code className="font-mono">MCP_ENCRYPTION_KEY</code> も
|
||||
別途必須。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel>Idle timeout (秒)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.idleTimeoutSeconds ?? 1800}
|
||||
onChange={v => onChange('ssh.console.idleTimeoutSeconds', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
I/O が一定秒無いセッションを auto-close する閾値。人間の入力も AI の入力も
|
||||
activity としてカウントする。デフォルト 1800 (30 分)。
|
||||
</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Max session duration (秒)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.maxSessionDurationSeconds ?? 14400}
|
||||
onChange={v => onChange('ssh.console.maxSessionDurationSeconds', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
1 セッションの絶対上限。Idle じゃなくてもこの時間を超えると強制 close。
|
||||
デフォルト 14400 (4 時間)。
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Scrollback bytes</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.scrollbackBytes ?? 524288}
|
||||
onChange={v => onChange('ssh.console.scrollbackBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
サーバー側で保持する PTY 出力履歴のリングバッファ容量。ブラウザ再接続時に
|
||||
replay される量。デフォルト 524288 (512 KiB)。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max sessions per connection</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.maxSessionsPerConnection ?? 3}
|
||||
onChange={v => onChange('ssh.console.maxSessionsPerConnection', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
同じ接続を使う並列セッション数の上限。超えた場合は最も古いセッションが
|
||||
<code className="font-mono">session_cap_evict</code> 理由で close される。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max input bytes per Send</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.maxInputBytesPerSend ?? 16384}
|
||||
onChange={v => onChange('ssh.console.maxInputBytesPerSend', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
1 回の <code className="font-mono">SshConsoleSend</code> で送れる最大バイト数。
|
||||
デフォルト 16384 (16 KiB)。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Auto-inject screen lines</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.autoInjectScreenLines ?? 24}
|
||||
onChange={v => onChange('ssh.console.autoInjectScreenLines', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
各 LLM iteration の system prompt 末尾に挿入する screen の末尾行数。
|
||||
多いほど AI の状況認識が良くなるが context を消費する。デフォルト 24 行。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel>Default cols</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.defaultCols ?? 120}
|
||||
onChange={v => onChange('ssh.console.defaultCols', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Default rows</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.defaultRows ?? 32}
|
||||
onChange={v => onChange('ssh.console.defaultRows', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpText>
|
||||
PTY サイズの初期値。クライアントが resize イベントを送れば上書きされる。
|
||||
</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { SshGlobalConnectionsForm } from './SshGlobalConnectionsForm';
|
||||
import { SshGrantsForm } from './SshGrantsForm';
|
||||
import { SshMasterKeyRotationForm } from './SshMasterKeyRotationForm';
|
||||
import { SshAuditLog } from './SshAuditLog';
|
||||
import { SshConfigForm } from './SshConfigForm';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
type SubTab = 'config' | 'connections' | 'grants' | 'rotation' | 'audit';
|
||||
|
||||
const TABS: { id: SubTab; label: string }[] = [
|
||||
{ id: 'config', label: 'Config' },
|
||||
{ id: 'connections', label: 'Global connections' },
|
||||
{ id: 'grants', label: 'Grants' },
|
||||
{ id: 'rotation', label: 'Rotation' },
|
||||
{ id: 'audit', label: 'Audit log' },
|
||||
];
|
||||
|
||||
interface Props extends SectionFormProps {
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin SSH panel — 5 sub-tabs:
|
||||
* - config: ssh.* + ssh.console.* config.yaml editor (uses the parent
|
||||
* draft / save-bar flow via SectionFormProps)
|
||||
* - connections: global SSH connection registry (own per-row CRUD)
|
||||
* - grants: per-user / per-piece access grants (own CRUD)
|
||||
* - rotation: master encryption key rotation (own state machine)
|
||||
* - audit: ssh_audit_log viewer with prune action
|
||||
*
|
||||
* Sub-tab layout because the 5 concerns share no vertical space and have
|
||||
* very different shapes (form / lists / per-row CRUD / mode / table).
|
||||
*/
|
||||
export function SshForm({ config, onChange, overriddenByEnv, showToast }: Props) {
|
||||
const [tab, setTab] = useState<SubTab>('config');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">SSH 管理</h2>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
グローバル SSH 設定 / 接続の登録 / アクセス権 (grants) / マスターキーローテーション / 監査ログ。
|
||||
ユーザー個人の SSH 接続は <code className="font-mono">User Folder</code> →{' '}
|
||||
<code className="font-mono">ssh-connections/</code> から管理します。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav className="flex items-center gap-1 border-b border-hairline">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors ${
|
||||
tab === t.id
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="pt-2">
|
||||
{tab === 'config' && <SshConfigForm config={config} onChange={onChange} overriddenByEnv={overriddenByEnv} />}
|
||||
{tab === 'connections' && <SshGlobalConnectionsForm showToast={showToast} />}
|
||||
{tab === 'grants' && <SshGrantsForm showToast={showToast} />}
|
||||
{tab === 'rotation' && <SshMasterKeyRotationForm showToast={showToast} />}
|
||||
{tab === 'audit' && <SshAuditLog />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SshConnection, TestResponse } from '../../lib/ssh-types';
|
||||
import { SshConnectionForm } from '../userfolder/SshConnectionForm';
|
||||
import { SshHostKeyDialog } from '../userfolder/SshHostKeyDialog';
|
||||
import { SshPublicKeyDialog } from '../userfolder/SshPublicKeyDialog';
|
||||
|
||||
async function fetchAdminConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
|
||||
const res = await fetch('/api/ssh/admin/connections', { credentials: 'include' });
|
||||
if (res.status === 404) return { list: [], sshDisabled: true };
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { connections: SshConnection[] };
|
||||
return { list: data.connections ?? [], sshDisabled: false };
|
||||
}
|
||||
|
||||
async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function patchJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function deleteJson(url: string, body: Record<string, unknown>): Promise<void> {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<string> {
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j?.error) return j.detail ? `${j.error}: ${typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail)}` : j.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
/** Notify parent when global connections changed (so grants section can re-fetch). */
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['ssh', 'admin', 'connections'],
|
||||
queryFn: fetchAdminConnections,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [reasonForOp, setReasonForOp] = useState<{ kind: 'disable' | 'enable' | 'delete' | 'forceUnlock'; conn: SshConnection } | 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);
|
||||
|
||||
function invalidate() {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'connections'] });
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
onChange?.();
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
postJson<{ connection: SshConnection; publicKey?: string | null }>('/api/ssh/admin/globals', body),
|
||||
onSuccess: (resp) => {
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
showToast?.('グローバル接続を作成しました', 'success');
|
||||
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 res = await fetch(`/api/ssh/admin/connections/${encodeURIComponent(id)}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
const data = (await res.json()) as { publicKey?: string | null };
|
||||
return { publicKey: data.publicKey ?? null, 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> }) =>
|
||||
patchJson<{ connection: SshConnection }>(`/api/ssh/admin/globals/${encodeURIComponent(id)}`, body),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setEditingId(null);
|
||||
showToast?.('グローバル接続を更新しました', 'success');
|
||||
},
|
||||
});
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
patchJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/disable`, { reason }),
|
||||
onSuccess: () => { invalidate(); showToast?.('接続を無効化しました', 'success'); },
|
||||
});
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
patchJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/enable`, { reason }),
|
||||
onSuccess: () => { invalidate(); showToast?.('接続を有効化しました', 'success'); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
deleteJson(`/api/ssh/admin/globals/${encodeURIComponent(id)}`, { reason }),
|
||||
onSuccess: () => { invalidate(); showToast?.('接続を削除しました', 'success'); },
|
||||
});
|
||||
const forceUnlockMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
postJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/force-unlock`, { reason }),
|
||||
onSuccess: () => { invalidate(); showToast?.('アビューズロックを解除しました', 'success'); },
|
||||
onError: (e) => { showToast?.(e instanceof Error ? e.message : 'unlock 失敗', 'error'); },
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<{ id: string; resp: TestResponse }> => {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/test`, { method: 'POST', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
return { id, resp: (await res.json()) as TestResponse };
|
||||
},
|
||||
onSuccess: ({ id, resp }) => {
|
||||
invalidate();
|
||||
if (resp.verdict === 'pass') {
|
||||
showToast?.(`ホストキーは一致しています (${resp.fingerprint.slice(0, 20)}…)`, 'success');
|
||||
} else if (resp.verdict === 'first_observe' || resp.verdict === 'mismatch') {
|
||||
setTestResult({ id, test: resp, replaceMode: resp.verdict === 'mismatch' });
|
||||
} else if (resp.verdict === 'alg_not_allowed') {
|
||||
showToast?.('ホストキーのアルゴリズムが許可リストにありません', 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => { showToast?.(e instanceof Error ? e.message : 'テスト失敗', 'error'); },
|
||||
});
|
||||
|
||||
async function handleVerify(connId: string, args: { fingerprint: string; token: string; reason?: string }) {
|
||||
const endpoint = args.reason ? 'replace-host-key' : 'verify-host-key';
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(connId)}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
invalidate();
|
||||
showToast?.('ホストキーを検証しました', 'success');
|
||||
}
|
||||
|
||||
if (data?.sshDisabled) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const globals = (data?.list ?? []).filter(c => c.ownerId === null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-900">グローバル接続 ({globals.length})</h3>
|
||||
<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="border border-accent/40 rounded-md bg-white p-4">
|
||||
<h4 className="text-xs font-semibold text-slate-700 mb-2">新規グローバル接続</h4>
|
||||
<SshConnectionForm
|
||||
existing={null}
|
||||
adminContext
|
||||
onSubmit={async (body) => { await createMutation.mutateAsync(body); }}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{globals.length === 0 && !creating && !isLoading && (
|
||||
<div className="text-xs text-slate-400 px-3 py-4">グローバル接続はまだありません。</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-hairline">
|
||||
{globals.map(c => (
|
||||
<li key={c.id} 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>
|
||||
<Badge color="slate">global</Badge>
|
||||
{c.hostKeyVerifiedAt ? (
|
||||
<Badge color="emerald">host-key verified</Badge>
|
||||
) : c.hostKeyPending ? (
|
||||
<Badge color="amber">host-key pending</Badge>
|
||||
) : (
|
||||
<Badge color="slate">host-key untested</Badge>
|
||||
)}
|
||||
{c.disabledByAdmin && <Badge color="red">admin-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 max-w-[280px]">
|
||||
<button onClick={() => testMutation.mutate(c.id)} className={btnCls} disabled={testMutation.isPending && testMutation.variables === c.id}>
|
||||
{testMutation.isPending && testMutation.variables === c.id ? 'テスト中…' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
||||
disabled={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
||||
title="authorized_keys に貼る公開鍵を表示"
|
||||
className={btnCls}
|
||||
>
|
||||
{showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id ? '取得中…' : '公開鍵'}
|
||||
</button>
|
||||
<button onClick={() => { setEditingId(c.id); setCreating(false); }} className={btnCls}>
|
||||
編集
|
||||
</button>
|
||||
<button onClick={() => setReasonForOp({ kind: 'forceUnlock', conn: c })} className={btnCls}>
|
||||
force-unlock
|
||||
</button>
|
||||
{c.disabledByAdmin ? (
|
||||
<button onClick={() => setReasonForOp({ kind: 'enable', conn: c })} className={btnCls}>有効化</button>
|
||||
) : (
|
||||
<button onClick={() => setReasonForOp({ kind: 'disable', conn: c })} className={btnCls}>無効化</button>
|
||||
)}
|
||||
<button onClick={() => setReasonForOp({ kind: 'delete', conn: c })} className={btnDangerCls}>削除</button>
|
||||
</div>
|
||||
</div>
|
||||
{editingId === c.id && (
|
||||
<div className="mt-3 ml-1 pl-3 border-l-2 border-accent/30">
|
||||
<SshConnectionForm
|
||||
existing={c}
|
||||
adminContext
|
||||
onSubmit={async (body) => { await patchMutation.mutateAsync({ id: c.id, body }); }}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{reasonForOp && (
|
||||
<ReasonModal
|
||||
title={
|
||||
reasonForOp.kind === 'delete' ? `削除: ${reasonForOp.conn.label}` :
|
||||
reasonForOp.kind === 'disable' ? `無効化: ${reasonForOp.conn.label}` :
|
||||
reasonForOp.kind === 'enable' ? `有効化: ${reasonForOp.conn.label}` :
|
||||
`force-unlock: ${reasonForOp.conn.label}`
|
||||
}
|
||||
warning={reasonForOp.kind === 'delete'}
|
||||
onCancel={() => setReasonForOp(null)}
|
||||
onSubmit={async (reason) => {
|
||||
const op = reasonForOp;
|
||||
setReasonForOp(null);
|
||||
if (op.kind === 'delete') await deleteMutation.mutateAsync({ id: op.conn.id, reason });
|
||||
else if (op.kind === 'disable') await disableMutation.mutateAsync({ id: op.conn.id, reason });
|
||||
else if (op.kind === 'enable') await enableMutation.mutateAsync({ id: op.conn.id, reason });
|
||||
else if (op.kind === 'forceUnlock') await forceUnlockMutation.mutateAsync({ id: op.conn.id, reason });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<SshHostKeyDialog
|
||||
test={testResult.test}
|
||||
replaceMode={testResult.replaceMode}
|
||||
onClose={() => setTestResult(null)}
|
||||
onVerify={(args) => handleVerify(testResult.id, args)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pubKeyDialog && (
|
||||
<SshPublicKeyDialog
|
||||
publicKey={pubKeyDialog.publicKey}
|
||||
label={pubKeyDialog.label}
|
||||
freshlyGenerated={pubKeyDialog.freshlyGenerated}
|
||||
onClose={() => setPubKeyDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReasonModalProps {
|
||||
title: string;
|
||||
warning?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (reason: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (reason.trim().length < 8) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(reason.trim());
|
||||
} 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-md bg-white rounded-md shadow-lg border border-hairline overflow-hidden">
|
||||
<div className={`px-4 py-3 border-b border-hairline ${warning ? 'bg-red-50' : ''}`}>
|
||||
<h3 className={`text-sm font-semibold ${warning ? 'text-red-800' : 'text-slate-900'}`}>{title}</h3>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<label className="block text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
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"
|
||||
placeholder="監査ログに残す理由を記述"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
|
||||
<button onClick={onCancel} disabled={submitting} className={btnCls}>キャンセル</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || reason.trim().length < 8}
|
||||
className={`px-3 h-7 text-xs font-semibold rounded-md disabled:opacity-50 ${warning ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-accent text-accent-fg hover:bg-accent-deep'}`}
|
||||
>
|
||||
{submitting ? '送信中…' : '実行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnCls = 'px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50';
|
||||
const btnDangerCls = 'px-2 h-7 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50 disabled:opacity-50';
|
||||
|
||||
/**
|
||||
* Click-to-copy connection UUID. Same UX as the user folder panel — agents
|
||||
* that ask "give me the connection_id" can be answered by clicking once.
|
||||
*/
|
||||
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 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,417 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SshConnection, SshGrant, SshGrantSubjectType } from '../../lib/ssh-types';
|
||||
|
||||
// NOTE: queryKey `['ssh', 'admin', 'connections']` is shared with SshGlobalConnectionsForm.
|
||||
// Both fetchers must return the same shape, otherwise the cache last-writer wins and
|
||||
// the other component reads an object where it expects an array — `.filter is not
|
||||
// a function` crash. Keep the `{list, sshDisabled}` shape in lockstep.
|
||||
async function fetchAdminConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
|
||||
const res = await fetch('/api/ssh/admin/connections', { credentials: 'include' });
|
||||
if (res.status === 404) return { list: [], sshDisabled: true };
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { connections: SshConnection[] };
|
||||
return { list: data.connections ?? [], sshDisabled: false };
|
||||
}
|
||||
|
||||
async function fetchAdminGrants(): Promise<{ list: SshGrant[]; sshDisabled: boolean }> {
|
||||
const res = await fetch('/api/ssh/admin/grants?limit=1000', { credentials: 'include' });
|
||||
if (res.status === 404) return { list: [], sshDisabled: true };
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { grants: SshGrant[] };
|
||||
return { list: data.grants ?? [], sshDisabled: false };
|
||||
}
|
||||
|
||||
async function fetchPieces(): Promise<string[]> {
|
||||
const res = await fetch('/api/pieces', { credentials: 'include' });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { pieces: Array<{ name: string }> };
|
||||
return (data.pieces ?? []).map(p => p.name).sort();
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: Record<string, unknown>): Promise<unknown> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const j = await res.json();
|
||||
detail = j?.error ? (j.detail ? `${j.error}: ${typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail)}` : j.error) : '';
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function deleteJson(url: string, body: Record<string, unknown>): Promise<void> {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin grant management. Lists all grants and lets the admin create new ones
|
||||
* (per-piece or applies-to-all) and delete existing ones (reason required).
|
||||
*
|
||||
* UI groups grants by global connection so it's easy to see who can use what.
|
||||
*/
|
||||
export function SshGrantsForm({ showToast }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const connQuery = useQuery({ queryKey: ['ssh', 'admin', 'connections'], queryFn: fetchAdminConnections, staleTime: 15_000 });
|
||||
const grantsQuery = useQuery({ queryKey: ['ssh', 'admin', 'grants'], queryFn: fetchAdminGrants, staleTime: 15_000 });
|
||||
const piecesQuery = useQuery({ queryKey: ['pieces', 'names'], queryFn: fetchPieces, staleTime: 60_000 });
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [reasonForDelete, setReasonForDelete] = useState<SshGrant | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => postJson('/api/ssh/admin/grants', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'grants'] });
|
||||
showToast?.('Grant を作成しました', 'success');
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
deleteJson(`/api/ssh/admin/grants/${encodeURIComponent(id)}`, { reason }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'grants'] });
|
||||
showToast?.('Grant を削除しました', 'success');
|
||||
},
|
||||
});
|
||||
|
||||
const sshDisabled = connQuery.data?.sshDisabled === true || grantsQuery.data?.sshDisabled === true;
|
||||
const globalConns = (connQuery.data?.list ?? []).filter(c => c.ownerId === null);
|
||||
const grantsByConn = new Map<string, SshGrant[]>();
|
||||
for (const g of grantsQuery.data?.list ?? []) {
|
||||
if (!grantsByConn.has(g.connectionId)) grantsByConn.set(g.connectionId, []);
|
||||
grantsByConn.get(g.connectionId)!.push(g);
|
||||
}
|
||||
|
||||
if (sshDisabled) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">アクセス権 (grants)</h3>
|
||||
<div className="border border-amber-200 rounded-md bg-amber-50 p-4 text-xs text-amber-900">
|
||||
<div className="font-semibold mb-1">SSH サブシステムが無効です</div>
|
||||
<div>
|
||||
<code className="font-mono">config.yaml</code> で <code className="font-mono">ssh.enabled: true</code> を設定し、
|
||||
環境変数 <code className="font-mono">MCP_ENCRYPTION_KEY</code> (64 hex chars) を export してから
|
||||
サーバーを再起動してください。詳細は <code className="font-mono">docs/ssh.md</code> を参照。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-900">アクセス権 (grants)</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
disabled={globalConns.length === 0}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
+ Grant を発行
|
||||
</button>
|
||||
</div>
|
||||
{globalConns.length === 0 && (
|
||||
<div className="text-xs text-slate-400 px-3 py-2">
|
||||
まずグローバル接続を登録してから grant を発行できます。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<CreateGrantForm
|
||||
connections={globalConns}
|
||||
pieces={piecesQuery.data ?? []}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onSubmit={async (body) => { await createMutation.mutateAsync(body); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{globalConns.map(c => {
|
||||
const grants = grantsByConn.get(c.id) ?? [];
|
||||
return (
|
||||
<section key={c.id} className="border border-hairline rounded-md bg-white">
|
||||
<header className="px-3 py-2 border-b border-hairline bg-surface/40 flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-slate-900 truncate">{c.label}</div>
|
||||
<div className="text-2xs text-slate-500 font-mono truncate">
|
||||
{c.username}@{c.host}:{c.port}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-2xs text-slate-500 font-mono">{grants.length} grants</span>
|
||||
</header>
|
||||
{grants.length === 0 ? (
|
||||
<div className="px-3 py-3 text-2xs text-slate-400">grant がありません — ユーザーは利用できません。</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{grants.map(g => (
|
||||
<li key={g.id} className="px-3 py-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xs">
|
||||
<span className="font-mono font-semibold">{g.subjectType}:{g.subjectId}</span>
|
||||
{g.appliesToAllPieces ? (
|
||||
<span className="ml-2 inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-50 text-amber-700">
|
||||
all pieces
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-2 text-slate-700">piece: <span className="font-mono">{g.pieceName}</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 mt-0.5">
|
||||
理由: {g.reason}
|
||||
{g.expiresAt && <> · 失効: <span className="font-mono">{g.expiresAt}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReasonForDelete(g)}
|
||||
className="px-2 h-6 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{reasonForDelete && (
|
||||
<ReasonModal
|
||||
title={`Grant を取り消す`}
|
||||
warning
|
||||
onCancel={() => setReasonForDelete(null)}
|
||||
onSubmit={async (reason) => {
|
||||
const g = reasonForDelete;
|
||||
setReasonForDelete(null);
|
||||
await deleteMutation.mutateAsync({ id: g.id, reason });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateGrantFormProps {
|
||||
connections: SshConnection[];
|
||||
pieces: string[];
|
||||
onSubmit: (body: Record<string, unknown>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGrantFormProps) {
|
||||
const [connectionId, setConnectionId] = useState(connections[0]?.id ?? '');
|
||||
const [subjectType, setSubjectType] = useState<SshGrantSubjectType>('user');
|
||||
const [subjectId, setSubjectId] = useState('');
|
||||
const [appliesToAll, setAppliesToAll] = useState(false);
|
||||
const [pieceName, setPieceName] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const valid =
|
||||
connectionId.length > 0 &&
|
||||
subjectId.trim().length > 0 &&
|
||||
(appliesToAll || pieceName.trim().length > 0) &&
|
||||
reason.trim().length >= 8;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!valid) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const body: Record<string, unknown> = {
|
||||
connectionId,
|
||||
subjectType,
|
||||
subjectId: subjectId.trim(),
|
||||
appliesToAllPieces: appliesToAll,
|
||||
reason: reason.trim(),
|
||||
};
|
||||
if (!appliesToAll) body.pieceName = pieceName.trim();
|
||||
if (expiresAt.trim().length > 0) body.expiresAt = expiresAt.trim();
|
||||
try {
|
||||
await onSubmit(body);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = 'w-full text-xs px-2 py-1.5 border border-hairline rounded';
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border border-accent/40 rounded-md bg-white p-4 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-slate-700">Grant を発行</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Global connection</div>
|
||||
<select value={connectionId} onChange={e => setConnectionId(e.target.value)} className={inputCls}>
|
||||
{connections.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Subject</div>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={subjectType}
|
||||
onChange={e => setSubjectType(e.target.value as SshGrantSubjectType)}
|
||||
className="shrink-0 w-20 text-xs px-2 py-1.5 border border-hairline rounded"
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="org">org</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={e => setSubjectId(e.target.value)}
|
||||
placeholder={subjectType === 'user' ? 'gitea ユーザー ID' : 'org ID'}
|
||||
className="flex-1 min-w-0 text-xs px-2 py-1.5 border border-hairline rounded font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appliesToAll}
|
||||
onChange={e => setAppliesToAll(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">すべてのピースで利用可能 (applies_to_all_pieces)</span>
|
||||
<span className="block text-2xs text-amber-700">
|
||||
⚠️ この grant は任意の piece からこの接続を使えるようにします。本当に必要なときのみ。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{!appliesToAll && (
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Piece name</div>
|
||||
<input
|
||||
type="text"
|
||||
value={pieceName}
|
||||
onChange={e => setPieceName(e.target.value)}
|
||||
placeholder="piece 名 (例: db-maintenance)"
|
||||
className={inputCls + ' font-mono'}
|
||||
list="ssh-grant-piece-list"
|
||||
required
|
||||
/>
|
||||
<datalist id="ssh-grant-piece-list">
|
||||
{pieces.map(p => <option key={p} value={p} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Expires at (任意, ISO8601)</div>
|
||||
<input
|
||||
type="text"
|
||||
value={expiresAt}
|
||||
onChange={e => setExpiresAt(e.target.value)}
|
||||
placeholder="2026-12-31T00:00:00Z"
|
||||
className={inputCls + ' font-mono'}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Reason (≥ 8 chars)</div>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="運用上の理由"
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{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={!valid || submitting} className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50">
|
||||
{submitting ? '発行中…' : '発行'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReasonModalProps {
|
||||
title: string;
|
||||
warning?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (reason: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (reason.trim().length < 8) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try { await onSubmit(reason.trim()); }
|
||||
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-md bg-white rounded-md shadow-lg border border-hairline overflow-hidden">
|
||||
<div className={`px-4 py-3 border-b border-hairline ${warning ? 'bg-red-50' : ''}`}>
|
||||
<h3 className={`text-sm font-semibold ${warning ? 'text-red-800' : 'text-slate-900'}`}>{title}</h3>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<label className="block text-2xs font-semibold text-slate-500 uppercase tracking-wide">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"
|
||||
placeholder="監査ログに残す理由を記述"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
|
||||
<button onClick={onCancel} disabled={submitting} className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50">キャンセル</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || reason.trim().length < 8}
|
||||
className={`px-3 h-7 text-xs font-semibold rounded-md disabled:opacity-50 ${warning ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-accent text-accent-fg hover:bg-accent-deep'}`}
|
||||
>
|
||||
{submitting ? '送信中…' : '実行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface RotationStub {
|
||||
jobId: string;
|
||||
status: string;
|
||||
startedAt?: string;
|
||||
progress?: { note: string };
|
||||
notImplemented?: boolean;
|
||||
}
|
||||
|
||||
interface StartResponse {
|
||||
jobId: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
notImplemented?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the rotation status by reading the maintenance flag indirectly.
|
||||
*
|
||||
* The /rotate-master-key/:jobId endpoint returns 404 when no job is active.
|
||||
* For v1 there's no "list all jobs" endpoint; we just track the latest jobId
|
||||
* locally and re-fetch its status.
|
||||
*/
|
||||
async function fetchJobStatus(jobId: string): Promise<RotationStub | null> {
|
||||
const res = await fetch(`/api/ssh/admin/rotate-master-key/${encodeURIComponent(jobId)}`, { credentials: 'include' });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as RotationStub;
|
||||
}
|
||||
|
||||
async function startRotation(reason: string): Promise<StartResponse> {
|
||||
const res = await fetch('/api/ssh/admin/rotate-master-key', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j?.error) detail = j.detail ? `${j.error}: ${typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail)}` : j.error;
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as StartResponse;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
export function SshMasterKeyRotationForm({ showToast }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
||||
const [showStartDialog, setShowStartDialog] = useState(false);
|
||||
|
||||
// Poll status every 3s while a job is known.
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['ssh', 'admin', 'rotation', activeJobId],
|
||||
queryFn: () => activeJobId ? fetchJobStatus(activeJobId) : Promise.resolve(null),
|
||||
refetchInterval: activeJobId ? 3000 : false,
|
||||
enabled: activeJobId !== null,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: startRotation,
|
||||
onSuccess: (resp) => {
|
||||
setActiveJobId(resp.jobId);
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'rotation'] });
|
||||
showToast?.(`Rotation job 開始: ${resp.jobId}`, 'success');
|
||||
setShowStartDialog(false);
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : 'Rotation 開始失敗', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const status = statusQuery.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Master Key Rotation</h3>
|
||||
<p className="text-2xs text-slate-500 mt-1 leading-relaxed">
|
||||
<code className="font-mono">MCP_ENCRYPTION_KEY</code> をローテーションします。実行中は SSH 書き込み系
|
||||
API が 503 を返し、UI からは保存・更新が一時停止します (読み取りは可能)。
|
||||
{' '}<strong>v1 ではメンテナンスフラグの設定のみ。実際の DEK 再ラップは未実装。</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-hairline bg-white p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">現在の状態</div>
|
||||
<div className="text-xs text-slate-800 mt-0.5">
|
||||
{activeJobId === null && <span>idle (rotation 未実行)</span>}
|
||||
{activeJobId !== null && statusQuery.isLoading && <span className="text-slate-400">確認中…</span>}
|
||||
{activeJobId !== null && status === null && (
|
||||
<span className="text-emerald-700">job {activeJobId} は完了またはクリア済み</span>
|
||||
)}
|
||||
{status && (
|
||||
<>
|
||||
<span className="font-mono">{status.status}</span>
|
||||
{status.notImplemented && (
|
||||
<span className="ml-2 inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-50 text-amber-700">
|
||||
stub (v1)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{status?.startedAt && (
|
||||
<div className="text-2xs text-slate-500 mt-0.5">開始: {status.startedAt}</div>
|
||||
)}
|
||||
{status?.progress?.note && (
|
||||
<div className="text-2xs text-slate-500 mt-0.5">{status.progress.note}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowStartDialog(true)}
|
||||
disabled={status !== null && status !== undefined}
|
||||
className="px-3 h-7 text-xs font-semibold bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
Rotation を開始
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showStartDialog && (
|
||||
<ConfirmDialog
|
||||
submitting={startMutation.isPending}
|
||||
onCancel={() => setShowStartDialog(false)}
|
||||
onSubmit={async (reason) => { await startMutation.mutateAsync(reason); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDialog({
|
||||
submitting,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (reason: string) => Promise<void>;
|
||||
}) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [typed, setTyped] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reasonValid = reason.trim().length >= 8;
|
||||
const typedOk = typed.trim().toUpperCase() === 'ROTATE';
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!reasonValid || !typedOk) return;
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(reason.trim());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
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-amber-300 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-amber-200 bg-amber-50">
|
||||
<h3 className="text-sm font-semibold text-amber-900">⚠️ Master Key Rotation を開始</h3>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<p className="text-xs text-slate-700 leading-relaxed">
|
||||
この操作は<strong>メンテナンスモードを有効化</strong>します。
|
||||
SSH 接続の作成・更新・削除・テストが一時的にすべて 503 を返します。
|
||||
<br />
|
||||
⚠️ v1 では DEK 再ラップは未実装です。 メンテナンスを解除するには手動でフラグをクリアする必要があります。
|
||||
</p>
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Reason (≥ 8 chars)</div>
|
||||
<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"
|
||||
placeholder="MCP_ENCRYPTION_KEY を新しい値に置き換えるため"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
|
||||
確認のため <code className="font-mono">ROTATE</code> と入力してください
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={e => setTyped(e.target.value)}
|
||||
className="w-full text-xs px-2 py-1.5 border border-hairline rounded font-mono"
|
||||
placeholder="ROTATE"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
|
||||
<button onClick={onCancel} disabled={submitting} className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50">
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!reasonValid || !typedOk || submitting}
|
||||
className="px-3 h-7 text-xs font-semibold bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '開始中…' : 'Rotation を開始'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface StringArrayEditorProps {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function StringArrayEditor({ value, onChange, placeholder }: StringArrayEditorProps) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
onChange([...value, trimmed]);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 mb-1">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep"
|
||||
>
|
||||
追加
|
||||
</button>
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{value.map((item, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-slate-100 text-slate-700 rounded">
|
||||
{item}
|
||||
<button onClick={() => handleRemove(i)} className="text-slate-400 hover:text-red-500">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { useToolList } from '../../hooks/useTools';
|
||||
import type { ToolCatalogEntry } from '../../api';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
export interface ToolTagInputProps {
|
||||
value: string[];
|
||||
onChange: (tools: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Piece `allowed_tools` editor backed by the runtime tool catalog
|
||||
* (`GET /api/tools`, see src/bridge/tools-api.ts).
|
||||
*
|
||||
* Behaviour:
|
||||
* - Groups tools by `source` then `category` (builtin core/web/.../mcp:<server>).
|
||||
* - Renders a `scope` badge (global/piece/user) on every entry.
|
||||
* - Unavailable entries (e.g. MCP server offline) are shown disabled with a
|
||||
* warning badge — they are NOT auto-removed from the piece, the user has to
|
||||
* delete them explicitly. This matches the design contract that a transient
|
||||
* MCP outage must never silently drop tools from a piece.
|
||||
* - Tools already on the piece but missing from the catalog appear under an
|
||||
* "unknown" group with the same disabled+warning treatment.
|
||||
* - Selecting an unavailable catalog tool is still allowed (the user might be
|
||||
* preparing for a server that's about to come back online).
|
||||
*/
|
||||
export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
|
||||
const { data: catalog } = useToolList();
|
||||
const [input, setInput] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const catalogByName = useMemo(() => {
|
||||
const m = new Map<string, ToolCatalogEntry>();
|
||||
for (const t of catalog ?? []) m.set(t.name, t);
|
||||
return m;
|
||||
}, [catalog]);
|
||||
|
||||
// Suggestions: catalog entries not already in `value`, filtered by input
|
||||
// substring. Unavailable entries stay in the suggestion list (user might
|
||||
// want to "pre-attach" a tool for a server they expect to come online).
|
||||
const suggestions = useMemo(() => {
|
||||
const q = input.toLowerCase();
|
||||
return (catalog ?? [])
|
||||
.filter((t) => !value.includes(t.name))
|
||||
.filter((t) => t.name.toLowerCase().includes(q));
|
||||
}, [catalog, value, input]);
|
||||
|
||||
// Groups for the suggestion dropdown.
|
||||
// Group key format:
|
||||
// builtin → 'builtin:<category>'
|
||||
// meta → 'meta'
|
||||
// mcp → 'mcp:<serverId|category>'
|
||||
const groupedSuggestions = useMemo(() => {
|
||||
const groups = new Map<string, { label: string; entries: ToolCatalogEntry[] }>();
|
||||
for (const t of suggestions) {
|
||||
const { key, label } = groupKeyForCatalogEntry(t);
|
||||
const g = groups.get(key);
|
||||
if (g) g.entries.push(t);
|
||||
else groups.set(key, { label, entries: [t] });
|
||||
}
|
||||
return Array.from(groups.entries()).map(([key, v]) => ({ key, ...v }));
|
||||
}, [suggestions]);
|
||||
|
||||
// Flat list of suggestions in displayed order — used to map keyboard
|
||||
// highlight index back to the actual entry.
|
||||
const flatSuggestions = useMemo(
|
||||
() => groupedSuggestions.flatMap((g) => g.entries),
|
||||
[groupedSuggestions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(0);
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const addTool = (tool: string) => {
|
||||
if (!value.includes(tool)) onChange([...value, tool]);
|
||||
setInput('');
|
||||
setShowDropdown(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const removeTool = (tool: string) => {
|
||||
onChange(value.filter((t) => t !== tool));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && flatSuggestions.length > 0 && showDropdown) {
|
||||
e.preventDefault();
|
||||
const pick = flatSuggestions[highlightIndex] ?? flatSuggestions[0];
|
||||
if (pick) addTool(pick.name);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => Math.min(i + 1, flatSuggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowDropdown(false);
|
||||
} else if (e.key === 'Backspace' && input === '' && value.length > 0) {
|
||||
removeTool(value[value.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">allowed_tools</label>
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="flex flex-wrap gap-1 p-2 border border-slate-300 rounded-lg min-h-[38px] focus-within:ring-2 focus-within:ring-accent-ring focus-within:border-accent">
|
||||
{value.map((tool) => {
|
||||
const entry = catalogByName.get(tool);
|
||||
return (
|
||||
<SelectedToolChip
|
||||
key={tool}
|
||||
name={tool}
|
||||
entry={entry}
|
||||
onRemove={() => removeTool(tool)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
|
||||
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
{showDropdown && groupedSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-slate-200 rounded-lg shadow-lg">
|
||||
{groupedSuggestions.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div className="sticky top-0 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500 bg-slate-50 border-b border-slate-100">
|
||||
{g.label}
|
||||
</div>
|
||||
{g.entries.map((t) => {
|
||||
const flatIdx = flatSuggestions.indexOf(t);
|
||||
const highlighted = flatIdx === highlightIndex;
|
||||
return (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onClick={() => addTool(t.name)}
|
||||
title={t.available ? undefined : t.reason ?? 'unavailable'}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 ${
|
||||
highlighted ? 'bg-accent-soft text-accent' : 'text-slate-700 hover:bg-slate-50'
|
||||
} ${t.available ? '' : 'opacity-70'}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{t.name}</span>
|
||||
<ScopeBadge scope={t.scope} />
|
||||
{!t.available && (
|
||||
<Badge color="amber">{t.reason ?? 'unavailable'}</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<HelpText>
|
||||
ここに列挙したツールのみ LLM に提示されます。
|
||||
オフラインの MCP ツールや未知のツールも自動削除されず、明示的に削除するまで残ります。
|
||||
</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable group key + human label for a catalog entry. MCP tools are
|
||||
* grouped per server id so the editor can show e.g. "MCP · github" sections.
|
||||
*/
|
||||
function groupKeyForCatalogEntry(t: ToolCatalogEntry): { key: string; label: string } {
|
||||
if (t.source === 'meta') return { key: 'meta', label: 'meta (always available)' };
|
||||
if (t.source === 'mcp') {
|
||||
const id = t.serverId ?? t.category.replace(/^mcp:/, '');
|
||||
return { key: `mcp:${id}`, label: `mcp · ${id}` };
|
||||
}
|
||||
return { key: `builtin:${t.category}`, label: `builtin · ${t.category}` };
|
||||
}
|
||||
|
||||
function SelectedToolChip({
|
||||
name,
|
||||
entry,
|
||||
onRemove,
|
||||
}: {
|
||||
name: string;
|
||||
entry: ToolCatalogEntry | undefined;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const isUnknown = !entry;
|
||||
const isUnavailable = entry ? !entry.available : false;
|
||||
// Visual stack:
|
||||
// - normal tool → slate chip
|
||||
// - unavailable in catalog → amber chip + reason badge
|
||||
// - unknown (not in catalog at all) → amber chip + "unknown" badge
|
||||
const tone =
|
||||
isUnknown || isUnavailable
|
||||
? 'bg-amber-50 text-amber-800 border border-amber-200'
|
||||
: 'bg-slate-100 text-slate-700';
|
||||
const tip = isUnknown
|
||||
? 'このツールは現在のカタログに存在しません。明示削除するまで保持されます。'
|
||||
: isUnavailable
|
||||
? (entry?.reason ?? 'unavailable')
|
||||
: undefined;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded ${tone}`}
|
||||
title={tip}
|
||||
>
|
||||
<span>{name}</span>
|
||||
{entry && <ScopeBadge scope={entry.scope} dim />}
|
||||
{isUnknown && <Badge color="amber">unknown</Badge>}
|
||||
{!isUnknown && isUnavailable && <Badge color="amber">{entry?.reason ?? 'offline'}</Badge>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-current opacity-60 hover:opacity-100"
|
||||
aria-label={`remove ${name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeBadge({ scope, dim }: { scope: 'global' | 'piece' | 'user'; dim?: boolean }) {
|
||||
const color: 'slate' | 'blue' | 'emerald' =
|
||||
scope === 'global' ? 'slate' : scope === 'user' ? 'emerald' : 'blue';
|
||||
return (
|
||||
<Badge color={color} dim={dim}>
|
||||
{scope}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({
|
||||
color,
|
||||
dim,
|
||||
children,
|
||||
}: {
|
||||
color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red';
|
||||
dim?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cls: Record<typeof color, string> = {
|
||||
slate: 'bg-slate-100 text-slate-600',
|
||||
blue: 'bg-blue-50 text-blue-700',
|
||||
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]} ${dim ? 'opacity-70' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* External Services settings — credentials and gates for third-party
|
||||
* API integrations (X / Twitter, Maps, Amazon / Keepa) plus the
|
||||
* user-supplied scripts security gate.
|
||||
*
|
||||
* Replaces the `x` / `maps` / `amazon` / `user-folder` tabs of the
|
||||
* legacy grab-bag `ToolsForm`. The config keys are unchanged:
|
||||
*
|
||||
* tools.x_auth_token / x_ct0 / x_cli_command / x_timeout / x_proxy / x_chrome_profile
|
||||
* tools.google_maps_api_key
|
||||
* tools.amazon_affiliate_tag / keepa_api_key
|
||||
* tools.user_scripts_enabled / user_scripts_allow_userids
|
||||
*
|
||||
* Note: trash_retention_days lives in Paths & Storage (storage.*) since
|
||||
* config v2 normalization (#360/#362). It is intentionally NOT shown
|
||||
* here — see PathsStorageForm.
|
||||
*/
|
||||
export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-slate-800">External Services</h2>
|
||||
|
||||
<section className="space-y-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
X / Twitter
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>X Auth Token</FieldLabel>
|
||||
<FieldInput type="password" value={tools.xAuthToken ?? ''} onChange={v => onChange('tools.xAuthToken', v)} />
|
||||
<HelpText>X / Twitter の auth_token cookie</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X ct0</FieldLabel>
|
||||
<FieldInput type="password" value={tools.xCt0 ?? ''} onChange={v => onChange('tools.xCt0', v)} />
|
||||
<HelpText>X / Twitter の ct0 cookie</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X CLI Command</FieldLabel>
|
||||
<FieldInput value={Array.isArray(tools.xCliCommand) ? tools.xCliCommand.join(' ') : (tools.xCliCommand ?? '')}
|
||||
onChange={v => onChange('tools.xCliCommand', v)} />
|
||||
<HelpText>twitter-cli の実行コマンド。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xTimeout ?? 90}
|
||||
onChange={v => onChange('tools.xTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Proxy</FieldLabel>
|
||||
<FieldInput value={tools.xProxy ?? ''} onChange={v => onChange('tools.xProxy', v)}
|
||||
placeholder="http://proxy:port" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Chrome Profile</FieldLabel>
|
||||
<FieldInput value={tools.xChromeProfile ?? ''} onChange={v => onChange('tools.xChromeProfile', v)}
|
||||
placeholder="/path/to/chrome/profile" />
|
||||
<HelpText>Cookie 抽出用の Chrome プロファイルディレクトリ。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Maps
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>Google Maps API Key</FieldLabel>
|
||||
<FieldInput type="password" value={tools.googleMapsApiKey ?? ''} onChange={v => onChange('tools.googleMapsApiKey', v)} />
|
||||
<HelpText>Google Maps Places / Directions API キー。未設定時は Nominatim / OSRM(無料)を使用。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Amazon / Keepa
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>Amazon Affiliate Tag</FieldLabel>
|
||||
<FieldInput value={tools.amazonAffiliateTag ?? ''} onChange={v => onChange('tools.amazonAffiliateTag', v)}
|
||||
placeholder="your-tag-22" />
|
||||
<HelpText>SearchAmazon で使用するアソシエイトタグ。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Keepa API Key</FieldLabel>
|
||||
<FieldInput type="password" value={tools.keepaApiKey ?? ''} onChange={v => onChange('tools.keepaApiKey', v)} />
|
||||
<HelpText>Keepa API キー(価格履歴データ取得用)。未設定でもグラフ画像リンクは提供されます。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
User-supplied Scripts
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>RunUserScript を有効化</FieldLabel>
|
||||
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tools.userScriptsEnabled === true}
|
||||
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
|
||||
/>
|
||||
<span>有効 (LLM の RunUserScript + scheduled script task が動作)</span>
|
||||
</label>
|
||||
<HelpText>
|
||||
plain runtime は Node <code>--permission</code> で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。
|
||||
browser-macros は Playwright の要件 (child_process / native bindings / network) で sandbox 不可、フル Node.js capability。
|
||||
信頼できるユーザーのみに有効化。
|
||||
</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>実行許可ユーザー allowlist (空欄 = 全員)</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={tools.userScriptsAllowUserids ?? []}
|
||||
onChange={v => onChange('tools.userScriptsAllowUserids', v)}
|
||||
placeholder="user id (例: 12345)"
|
||||
/>
|
||||
<HelpText>
|
||||
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ RunUserScript / scheduled script task が許可される。
|
||||
</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import { NamespaceEditor } from './NamespaceEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
const TOOL_TABS = [
|
||||
{ id: 'web', label: 'Web' },
|
||||
{ id: 'vision', label: 'Vision / OCR' },
|
||||
{ id: 'x', label: 'X / Twitter' },
|
||||
{ id: 'maps', label: 'Maps' },
|
||||
{ id: 'amazon', label: 'Amazon' },
|
||||
{ id: 'speech', label: 'Speech' },
|
||||
{ id: 'knowledge', label: 'Knowledge (DKS) [LEGACY]' },
|
||||
{ id: 'office', label: 'Office' },
|
||||
{ id: 'uploads', label: 'Uploads' },
|
||||
{ id: 'user-folder', label: 'User Folder' },
|
||||
] as const;
|
||||
|
||||
export type ToolTabId = (typeof TOOL_TABS)[number]['id'];
|
||||
|
||||
interface ToolsFormProps extends SectionFormProps {
|
||||
/**
|
||||
* Restrict the visible set of sub-tabs. When omitted, all tabs are shown.
|
||||
* Used by the Settings sidebar Step 3 restructure to route sub-section ids
|
||||
* (tools-web / tools-browser / tools-media / tools-external /
|
||||
* tools-legacy-knowledge) into the same ToolsForm with a narrowed scope.
|
||||
*/
|
||||
visibleTabs?: readonly ToolTabId[];
|
||||
}
|
||||
|
||||
export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
const tools = config.tools ?? {};
|
||||
const tabsToShow = visibleTabs && visibleTabs.length > 0
|
||||
? TOOL_TABS.filter(t => visibleTabs.includes(t.id))
|
||||
: TOOL_TABS;
|
||||
const [tab, setTab] = useState<ToolTabId>(tabsToShow[0]?.id ?? 'web');
|
||||
|
||||
// When visibleTabs changes (sidebar section change), reset to first tab
|
||||
// of the new scope so we don't render a hidden tab's content.
|
||||
useEffect(() => {
|
||||
if (!tabsToShow.some(t => t.id === tab)) {
|
||||
setTab(tabsToShow[0]?.id ?? 'web');
|
||||
}
|
||||
}, [tabsToShow, tab]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Tools</h2>
|
||||
|
||||
<nav className="flex flex-wrap gap-1 border-b border-hairline -mt-2" aria-label="ツールカテゴリ">
|
||||
{tabsToShow.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
aria-current={tab === t.id ? 'page' : undefined}
|
||||
className={`px-3 py-1.5 text-xs border-b-2 -mb-px transition-colors ${
|
||||
tab === t.id
|
||||
? 'border-accent font-semibold text-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'web' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>SearXNG URL</FieldLabel>
|
||||
<FieldInput value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
|
||||
<HelpText>WebSearch のフォールバック用 SearXNG エンドポイント。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebFetch Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.webfetchTimeout ?? 30}
|
||||
onChange={v => onChange('tools.webfetchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebSearch Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.websearchTimeout ?? 15}
|
||||
onChange={v => onChange('tools.websearchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>SSRF Allowed Hosts</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={tools.webfetchAllowedHosts ?? []}
|
||||
onChange={v => onChange('tools.webfetchAllowedHosts', v)}
|
||||
placeholder="hostname or IP address" />
|
||||
<HelpText>SSRF 保護の例外ホスト名/IP アドレス。WebFetch・BrowseWeb のすべてに適用。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'vision' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>Vision Model</FieldLabel>
|
||||
<FieldInput value={tools.visionModel ?? ''} onChange={v => onChange('tools.visionModel', v)} />
|
||||
<HelpText>画像分析に使用するモデル名(例: qwen2-vl:8b-instruct)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Base URL</FieldLabel>
|
||||
<FieldInput value={tools.visionBaseUrl ?? ''} onChange={v => onChange('tools.visionBaseUrl', v)}
|
||||
placeholder="Provider の Base URL と同じ場合は空欄" />
|
||||
<HelpText>Vision モデル用の API エンドポイント。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.visionTimeout ?? 60}
|
||||
onChange={v => onChange('tools.visionTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Max Tokens</FieldLabel>
|
||||
<FieldInput type="number" value={tools.visionMaxTokens ?? 1024}
|
||||
onChange={v => onChange('tools.visionMaxTokens', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>OCR Model</FieldLabel>
|
||||
<FieldInput value={tools.ocrModel ?? ''} onChange={v => onChange('tools.ocrModel', v)}
|
||||
placeholder="glm-ocr" />
|
||||
<HelpText>GLM-OCR で使用するモデル名。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'x' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>X Auth Token</FieldLabel>
|
||||
<FieldInput type="password" value={tools.xAuthToken ?? ''} onChange={v => onChange('tools.xAuthToken', v)} />
|
||||
<HelpText>X / Twitter の auth_token cookie</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X ct0</FieldLabel>
|
||||
<FieldInput type="password" value={tools.xCt0 ?? ''} onChange={v => onChange('tools.xCt0', v)} />
|
||||
<HelpText>X / Twitter の ct0 cookie</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X CLI Command</FieldLabel>
|
||||
<FieldInput value={Array.isArray(tools.xCliCommand) ? tools.xCliCommand.join(' ') : (tools.xCliCommand ?? '')}
|
||||
onChange={v => onChange('tools.xCliCommand', v)} />
|
||||
<HelpText>twitter-cli の実行コマンド。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xTimeout ?? 90}
|
||||
onChange={v => onChange('tools.xTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Proxy</FieldLabel>
|
||||
<FieldInput value={tools.xProxy ?? ''} onChange={v => onChange('tools.xProxy', v)}
|
||||
placeholder="http://proxy:port" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Chrome Profile</FieldLabel>
|
||||
<FieldInput value={tools.xChromeProfile ?? ''} onChange={v => onChange('tools.xChromeProfile', v)}
|
||||
placeholder="/path/to/chrome/profile" />
|
||||
<HelpText>Cookie 抽出用の Chrome プロファイルディレクトリ。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'maps' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>Google Maps API Key</FieldLabel>
|
||||
<FieldInput type="password" value={tools.googleMapsApiKey ?? ''} onChange={v => onChange('tools.googleMapsApiKey', v)} />
|
||||
<HelpText>Google Maps Places / Directions API キー。未設定時は Nominatim / OSRM(無料)を使用。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'amazon' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>Amazon Affiliate Tag</FieldLabel>
|
||||
<FieldInput value={tools.amazonAffiliateTag ?? ''} onChange={v => onChange('tools.amazonAffiliateTag', v)}
|
||||
placeholder="your-tag-22" />
|
||||
<HelpText>SearchAmazon で使用するアソシエイトタグ。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Keepa API Key</FieldLabel>
|
||||
<FieldInput type="password" value={tools.keepaApiKey ?? ''} onChange={v => onChange('tools.keepaApiKey', v)} />
|
||||
<HelpText>Keepa API キー(価格履歴データ取得用)。未設定でもグラフ画像リンクは提供されます。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'speech' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>Speech Server URL</FieldLabel>
|
||||
<FieldInput value={tools.speechServerUrl ?? ''} onChange={v => onChange('tools.speechServerUrl', v)}
|
||||
placeholder="http://localhost:8000/v1" />
|
||||
<HelpText>音声認識サーバーの API エンドポイント(TranscribeAudio 用)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.speechTimeout ?? 300}
|
||||
onChange={v => onChange('tools.speechTimeout', Number(v))} />
|
||||
<HelpText>長い音声ファイルに対応するためのタイムアウト</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Language</FieldLabel>
|
||||
<FieldInput value={tools.speechLanguage ?? 'ja'} onChange={v => onChange('tools.speechLanguage', v)}
|
||||
placeholder="ja" />
|
||||
<HelpText>文字起こしのデフォルト言語コード</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'knowledge' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Knowledge (DKS)</h3>
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-100 text-amber-800 border border-amber-300"
|
||||
title="この機能は legacy です。新規の知識検索統合は MCP server 経由を推奨"
|
||||
>
|
||||
LEGACY
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="note"
|
||||
className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900"
|
||||
>
|
||||
DKS 機能は <strong>legacy</strong> 化されており、新規の知識検索統合は{' '}
|
||||
<strong>MCP server 経由</strong> を推奨します。既存の namespace 設定は引き続き動作しますが、
|
||||
新規 namespace の追加はできません。{' '}
|
||||
<a
|
||||
href="/help"
|
||||
className="underline text-amber-900 hover:text-amber-700"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MCP 連携ガイドを開く
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Knowledge Service URL</FieldLabel>
|
||||
<FieldInput value={tools.knowledgeServiceUrl ?? ''} onChange={v => onChange('tools.knowledgeServiceUrl', v)}
|
||||
placeholder="http://dks-server:8100" />
|
||||
<HelpText>Document Knowledge Server (DKS) の API エンドポイント。未設定時は knowledge ツール無効。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Knowledge Namespaces</FieldLabel>
|
||||
<NamespaceEditor
|
||||
value={tools.knowledgeNamespaces ?? {}}
|
||||
onChange={v => onChange('tools.knowledgeNamespaces', v)}
|
||||
addDisabled
|
||||
addDisabledReason="新規 namespace 追加は MCP 経由を推奨"
|
||||
addDisabledHref="/help"
|
||||
/>
|
||||
<HelpText>DKS の名前空間と API キーの組み合わせ。既存項目の編集・削除は可能ですが、新規追加は無効化されています。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'user-folder' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>RunUserScript を有効化</FieldLabel>
|
||||
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tools.userScriptsEnabled === true}
|
||||
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
|
||||
/>
|
||||
<span>有効 (LLM の RunUserScript + scheduled script task が動作)</span>
|
||||
</label>
|
||||
<HelpText>
|
||||
plain runtime は Node <code>--permission</code> で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。
|
||||
browser-macros は Playwright の要件 (child_process / native bindings / network) で sandbox 不可、フル Node.js capability。
|
||||
信頼できるユーザーのみに有効化。
|
||||
</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>実行許可ユーザー allowlist (空欄 = 全員)</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={tools.userScriptsAllowUserids ?? []}
|
||||
onChange={v => onChange('tools.userScriptsAllowUserids', v)}
|
||||
placeholder="user id (例: 12345)"
|
||||
/>
|
||||
<HelpText>
|
||||
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ RunUserScript / scheduled script task が許可される。
|
||||
</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Trash Retention (日)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.trashRetentionDays ?? 30}
|
||||
onChange={v => onChange('tools.trashRetentionDays', Number(v))} />
|
||||
<HelpText>
|
||||
<code>data/users/{userId}/trash/</code> のファイルを自動削除するまでの日数。
|
||||
起動時 + 24h 毎に sweep。0 を指定すると sweep のたびに即削除。デフォルト 30 日。
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'uploads' && (
|
||||
<div className="space-y-5">
|
||||
<HelpText>UI からのアップロード API のリクエスト body 上限 (MB)</HelpText>
|
||||
<div>
|
||||
<FieldLabel>タスク作成・コメント時の最大アップロードサイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.taskUploadMaxSizeMb ?? 50}
|
||||
onChange={v => onChange('tools.taskUploadMaxSizeMb', Number(v))} />
|
||||
<HelpText>
|
||||
<code>POST /api/local/tasks</code> および <code>POST /api/local/tasks/:id/comments</code> の
|
||||
リクエスト body 上限 (添付ファイルを base64 でエンコードした JSON 全体)。
|
||||
添付ファイルの実サイズは概ね <code>値 × 0.75</code> が目安 (例: 50 MB body ≒ 37 MB raw)。
|
||||
範囲は 1〜1000 MB にクランプ。デフォルト 50 MB。サーバ再起動なしで反映。
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'office' && (
|
||||
<div className="space-y-5">
|
||||
<HelpText>Office ファイルサイズ上限 (MB)</HelpText>
|
||||
<div>
|
||||
<FieldLabel>ReadExcel 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeExcelMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeExcelMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadExcel が受け付ける .xlsx / .xls ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadDocx 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeDocxMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeDocxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadDocx が受け付ける .docx ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPdf 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePdfMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officePdfMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPdf が受け付ける .pdf ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxSizeMb ?? 50}
|
||||
onChange={v => onChange('tools.officePptxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 展開後サイズ上限</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxUncompressedMb ?? 200}
|
||||
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
|
||||
<HelpText>PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Media & Documents settings.
|
||||
*
|
||||
* Replaces the `vision` / `speech` / `office` / `uploads` tabs of the
|
||||
* legacy grab-bag `ToolsForm`. The config keys are unchanged:
|
||||
*
|
||||
* tools.vision_model / vision_base_url / vision_timeout / vision_max_tokens
|
||||
* tools.ocr_model
|
||||
* tools.speech_server_url / speech_timeout / speech_language
|
||||
* tools.office_{excel,docx,pdf,pptx}_max_size_mb
|
||||
* tools.office_pptx_max_uncompressed_mb
|
||||
* tools.task_upload_max_size_mb
|
||||
*/
|
||||
export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-slate-800">Media & Documents</h2>
|
||||
|
||||
<section className="space-y-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Vision / OCR
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>Vision Model</FieldLabel>
|
||||
<FieldInput value={tools.visionModel ?? ''} onChange={v => onChange('tools.visionModel', v)} />
|
||||
<HelpText>画像分析に使用するモデル名(例: qwen2-vl:8b-instruct)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Base URL</FieldLabel>
|
||||
<FieldInput value={tools.visionBaseUrl ?? ''} onChange={v => onChange('tools.visionBaseUrl', v)}
|
||||
placeholder="Provider の Base URL と同じ場合は空欄" />
|
||||
<HelpText>Vision モデル用の API エンドポイント。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.visionTimeout ?? 60}
|
||||
onChange={v => onChange('tools.visionTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Max Tokens</FieldLabel>
|
||||
<FieldInput type="number" value={tools.visionMaxTokens ?? 1024}
|
||||
onChange={v => onChange('tools.visionMaxTokens', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>OCR Model</FieldLabel>
|
||||
<FieldInput value={tools.ocrModel ?? ''} onChange={v => onChange('tools.ocrModel', v)}
|
||||
placeholder="glm-ocr" />
|
||||
<HelpText>GLM-OCR で使用するモデル名。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Speech
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>Speech Server URL</FieldLabel>
|
||||
<FieldInput value={tools.speechServerUrl ?? ''} onChange={v => onChange('tools.speechServerUrl', v)}
|
||||
placeholder="http://localhost:8000/v1" />
|
||||
<HelpText>音声認識サーバーの API エンドポイント(TranscribeAudio 用)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.speechTimeout ?? 300}
|
||||
onChange={v => onChange('tools.speechTimeout', Number(v))} />
|
||||
<HelpText>長い音声ファイルに対応するためのタイムアウト</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Language</FieldLabel>
|
||||
<FieldInput value={tools.speechLanguage ?? 'ja'} onChange={v => onChange('tools.speechLanguage', v)}
|
||||
placeholder="ja" />
|
||||
<HelpText>文字起こしのデフォルト言語コード</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Office (file size limits)
|
||||
</h3>
|
||||
<HelpText>Office ファイルサイズ上限 (MB)</HelpText>
|
||||
<div>
|
||||
<FieldLabel>ReadExcel 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeExcelMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeExcelMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadExcel が受け付ける .xlsx / .xls ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadDocx 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeDocxMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeDocxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadDocx が受け付ける .docx ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPdf 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePdfMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officePdfMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPdf が受け付ける .pdf ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 最大サイズ</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxSizeMb ?? 50}
|
||||
onChange={v => onChange('tools.officePptxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 展開後サイズ上限</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxUncompressedMb ?? 200}
|
||||
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
|
||||
<HelpText>PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Uploads
|
||||
</h3>
|
||||
<HelpText>UI からのアップロード API のリクエスト body 上限 (MB)</HelpText>
|
||||
<div>
|
||||
<FieldLabel>タスク作成・コメント時の最大アップロードサイズ</FieldLabel>
|
||||
<FieldInput type="number" value={(config.storage?.taskUploadMaxSizeMb) ?? 50}
|
||||
onChange={v => onChange('storage.taskUploadMaxSizeMb', v ? Number(v) : undefined)} />
|
||||
<HelpText>
|
||||
<code>POST /api/local/tasks</code> および <code>POST /api/local/tasks/:id/comments</code> の
|
||||
リクエスト body 上限 (添付ファイルを base64 でエンコードした JSON 全体)。
|
||||
添付ファイルの実サイズは概ね <code>値 × 0.75</code> が目安 (例: 50 MB body ≒ 37 MB raw)。
|
||||
範囲は 1〜1000 MB にクランプ。デフォルト 50 MB。サーバ再起動なしで反映。
|
||||
この設定は <strong>Paths & Storage</strong> でも編集可能 (同じ <code>storage.task_upload_max_size_mb</code> キー)。
|
||||
</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Web & Search settings.
|
||||
*
|
||||
* Replaces the `web` tab of the legacy grab-bag `ToolsForm` and folds
|
||||
* the standalone `SearchFilterForm` in as a sub-section (Step 3
|
||||
* INVESTIGATE #3 follow-up). The config keys are unchanged:
|
||||
*
|
||||
* tools.searxng_url
|
||||
* tools.webfetch_timeout
|
||||
* tools.websearch_timeout
|
||||
* tools.webfetch_allowed_hosts
|
||||
* search_filter.blocked_patterns
|
||||
* search_filter.auto_block.*
|
||||
*/
|
||||
export function ToolsWebForm({ config, onChange }: SectionFormProps) {
|
||||
const tools = config.tools ?? {};
|
||||
const sf = config.searchFilter ?? {};
|
||||
const autoBlock = sf.autoBlock ?? {};
|
||||
|
||||
const toggleAutoBlock = (key: string, value: boolean) => {
|
||||
onChange(`searchFilter.autoBlock.${key}`, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-slate-800">Web & Search</h2>
|
||||
|
||||
<section className="space-y-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Web Fetch / Search
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>SearXNG URL</FieldLabel>
|
||||
<FieldInput value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
|
||||
<HelpText>WebSearch のフォールバック用 SearXNG エンドポイント。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebFetch Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.webfetchTimeout ?? 30}
|
||||
onChange={v => onChange('tools.webfetchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebSearch Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.websearchTimeout ?? 15}
|
||||
onChange={v => onChange('tools.websearchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>SSRF Allowed Hosts</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={tools.webfetchAllowedHosts ?? []}
|
||||
onChange={v => onChange('tools.webfetchAllowedHosts', v)}
|
||||
placeholder="hostname or IP address" />
|
||||
<HelpText>SSRF 保護の例外ホスト名/IP アドレス。WebFetch・BrowseWeb のすべてに適用。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 pt-2 border-t border-hairline">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Search Filter
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Blocked Patterns (ブロックパターン)</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={sf.blockedPatterns ?? []}
|
||||
onChange={v => onChange('searchFilter.blockedPatterns', v)}
|
||||
placeholder="regex pattern"
|
||||
/>
|
||||
<HelpText>WebSearch クエリからフィルタするパターン(正規表現)。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Auto Block (自動ブロック)</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{([
|
||||
['privateIp', 'プライベートIP', autoBlock.privateIp],
|
||||
['internalDomain', '内部ドメイン', autoBlock.internalDomain],
|
||||
['email', 'メールアドレス', autoBlock.email],
|
||||
['phone', '電話番号', autoBlock.phone],
|
||||
] as const).map(([key, label, checked]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={e => toggleAutoBlock(key, e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>検索クエリに含まれる機密情報を自動でブロック。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Workspace</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Worktree Directory</FieldLabel>
|
||||
<FieldInput
|
||||
value={config.worktreeDir ?? ''}
|
||||
onChange={v => onChange('worktreeDir', v)}
|
||||
disabled={!!overriddenByEnv['worktreeDir']}
|
||||
disabledReason="WORKTREE_DIR 環境変数で上書き中"
|
||||
/>
|
||||
{overriddenByEnv['worktreeDir'] && <EnvOverrideWarning />}
|
||||
<HelpText>ジョブ実行時の作業ディレクトリのベースパス</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Custom Pieces Directory</FieldLabel>
|
||||
<FieldInput value={config.customPiecesDir ?? ''} onChange={v => onChange('customPiecesDir', v || undefined)}
|
||||
placeholder="/path/to/your/custom-pieces" />
|
||||
<HelpText>リポジトリ内の pieces/ とは別に、追加の Piece を配置するディレクトリ。省略時は pieces/ のみ使用</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Concurrency</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={config.concurrency ?? ''}
|
||||
onChange={v => onChange('concurrency', v ? Number(v) : undefined)}
|
||||
disabled={!!overriddenByEnv['concurrency']}
|
||||
disabledReason="CONCURRENCY 環境変数で上書き中"
|
||||
/>
|
||||
{overriddenByEnv['concurrency'] && <EnvOverrideWarning />}
|
||||
<HelpText>同時実行可能なジョブ数</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Movements</FieldLabel>
|
||||
<FieldInput type="number" value={config.maxMovements ?? ''} onChange={v => onChange('maxMovements', v ? Number(v) : undefined)} />
|
||||
<HelpText>1ジョブあたりの最大 movement 数</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Retry</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Attempts</FieldLabel>
|
||||
<FieldInput type="number" value={config.retry?.maxAttempts ?? 3}
|
||||
onChange={v => onChange('retry.maxAttempts', Number(v))} />
|
||||
<HelpText>ジョブ失敗時の最大リトライ回数。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Backoff Seconds</FieldLabel>
|
||||
<FieldInput value={(config.retry?.backoffSeconds ?? [60, 300, 900]).join(', ')}
|
||||
onChange={v => onChange('retry.backoffSeconds', v.split(',').map((s: string) => Number(s.trim())).filter((n: number) => !isNaN(n)))} />
|
||||
<HelpText>リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export function EnvOverrideWarning() {
|
||||
return (
|
||||
<div className="text-2xs text-amber-700 bg-amber-50 border border-amber-100 px-2 py-1 rounded mt-1">
|
||||
環境変数で上書きされています(保存しても反映されません)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return <label className="block text-2xs font-medium text-slate-600 mb-1">{children}</label>;
|
||||
}
|
||||
|
||||
interface FieldInputProps {
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
type?: 'text' | 'number' | 'password';
|
||||
placeholder?: string;
|
||||
/** ENV 上書きなどで編集を無効化したい場合に true を渡す */
|
||||
disabled?: boolean;
|
||||
/** disabled の理由を tooltip として表示 */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export function FieldInput({ value, onChange, type = 'text', placeholder, disabled, disabledReason }: FieldInputProps) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
title={disabled ? disabledReason : undefined}
|
||||
className={`w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow ${
|
||||
disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : 'bg-white'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SectionFormProps {
|
||||
config: any;
|
||||
onChange: (path: string, value: any) => void;
|
||||
overriddenByEnv: Record<string, boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user