This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const ask = config.ask ?? {};
|
||||
const subtasks = config.subtasks ?? {};
|
||||
|
||||
@@ -13,19 +15,19 @@ export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
|
||||
<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>
|
||||
<HelpText>{t('askSubtasks.askMaxHelp')}</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>
|
||||
<HelpText>{t('askSubtasks.maxDepthHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Subtasks: Max Per Parent</FieldLabel>
|
||||
<FieldInput type="number" value={subtasks.maxPerParent ?? ''} onChange={v => onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} />
|
||||
<HelpText>1 つの親ジョブが spawn できるサブタスクの最大数。デフォルト: 10</HelpText>
|
||||
<HelpText>{t('askSubtasks.maxPerParentHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
@@ -13,6 +14,7 @@ import type { SectionFormProps } from './types';
|
||||
* mode — every visitor is treated as a local admin.
|
||||
*/
|
||||
export function AuthForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const auth = config.auth ?? {};
|
||||
const providers = auth.providers ?? {};
|
||||
const google = providers.google ?? {};
|
||||
@@ -21,82 +23,81 @@ export function AuthForm({ config, onChange }: SectionFormProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Authentication</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('auth.title')}</h2>
|
||||
<p className="text-[13px] text-slate-500">
|
||||
ログイン認証。providers を未設定にすると <strong>認証なし(全員ローカル admin)</strong>で動作する。
|
||||
変更後はサーバ再起動が必要。
|
||||
{t('auth.intro')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Primary Provider</FieldLabel>
|
||||
<FieldLabel>{t('auth.primaryProvider')}</FieldLabel>
|
||||
<select
|
||||
value={auth.primaryProvider ?? ''}
|
||||
onChange={e => onChange('auth.primaryProvider', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
|
||||
>
|
||||
<option value="">(指定なし・全て有効)</option>
|
||||
<option value="google">google のみ</option>
|
||||
<option value="gitea">gitea のみ</option>
|
||||
<option value="local">local のみ</option>
|
||||
<option value="">{t('auth.primaryNone')}</option>
|
||||
<option value="google">{t('auth.primaryGoogle')}</option>
|
||||
<option value="gitea">{t('auth.primaryGitea')}</option>
|
||||
<option value="local">{t('auth.primaryLocal')}</option>
|
||||
</select>
|
||||
<HelpText>単一プロバイダーに限定する場合に指定。未指定なら設定済みの全プロバイダー(OAuth + ローカル)でログイン可</HelpText>
|
||||
<HelpText>{t('auth.primaryHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Admin Emails</FieldLabel>
|
||||
<FieldLabel>{t('auth.adminEmails')}</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={auth.adminEmails ?? []}
|
||||
onChange={v => onChange('auth.adminEmails', v)}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
<HelpText>admin ロールを付与するメールアドレス</HelpText>
|
||||
<HelpText>{t('auth.adminEmailsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={auth.secureCookie === true}
|
||||
onChange={e => onChange('auth.secureCookie', e.target.checked)} className="rounded" />
|
||||
Secure Cookie(HTTPS のみで Cookie 送信)
|
||||
{t('auth.secureCookie')}
|
||||
</label>
|
||||
<HelpText>本番(HTTPS)では有効推奨。HTTP のローカル開発では無効</HelpText>
|
||||
<HelpText>{t('auth.secureCookieHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Session Max Age (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={auth.sessionMaxAge ?? ''}
|
||||
onChange={v => onChange('auth.sessionMaxAge', v ? Number(v) : undefined)} />
|
||||
<HelpText>セッションの有効期間(ミリ秒)</HelpText>
|
||||
<HelpText>{t('auth.sessionMaxAgeHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Session Secret</FieldLabel>
|
||||
<FieldInput type="password" value={auth.sessionSecret ?? ''}
|
||||
onChange={v => onChange('auth.sessionSecret', v)} />
|
||||
<HelpText>セッション署名鍵。十分長いランダム文字列を設定(保存後はマスク表示)</HelpText>
|
||||
<HelpText>{t('auth.sessionSecretHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">ローカルアカウント(email + password)</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('auth.localTitle')}</h3>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={local.enabled === true}
|
||||
onChange={e => onChange('auth.local.enabled', e.target.checked)} className="rounded" />
|
||||
ローカルログインを有効化
|
||||
{t('auth.localEnable')}
|
||||
</label>
|
||||
<HelpText>外部 IdP を立てずに email/password でログインできるようにする。OAuth と併用可(<code>primary_provider: local</code> で local のみに限定)</HelpText>
|
||||
<HelpText>{t('auth.localEnableHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={local.allowSignup === true}
|
||||
onChange={e => onChange('auth.local.allowSignup', e.target.checked)} className="rounded" />
|
||||
セルフ登録を許可
|
||||
{t('auth.allowSignup')}
|
||||
</label>
|
||||
<HelpText>ログイン画面に新規登録フォームを出す。登録は <strong>承認待ち</strong>で作成され、admin がユーザー管理で承認するまでログインできない</HelpText>
|
||||
<HelpText>{t('auth.allowSignupHelp')}</HelpText>
|
||||
</div>
|
||||
<HelpText>
|
||||
初回 admin(<code>bootstrap_admin</code>)は、UI に入るには既に admin ログインが必要なため、<code>config.yaml</code> の <code>auth.local.bootstrap_admin</code> で設定します(<code>id='local'</code> で seed され、no-auth 時代のデータを引き継ぎます)。以降のユーザーは <em>ユーザー管理</em> 画面で作成・パスワードリセットできます。
|
||||
{t('auth.bootstrapHelp')}
|
||||
</HelpText>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Google OAuth</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('auth.googleTitle')}</h3>
|
||||
<div>
|
||||
<FieldLabel>Client ID</FieldLabel>
|
||||
<FieldInput value={google.clientId ?? ''}
|
||||
@@ -113,7 +114,7 @@ export function AuthForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={v => onChange('auth.providers.google.callbackUrl', v)} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Gitea OAuth</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('auth.giteaTitle')}</h3>
|
||||
<div>
|
||||
<FieldLabel>Base URL</FieldLabel>
|
||||
<FieldInput value={gitea.baseUrl ?? ''} placeholder="https://gitea.example.com"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
@@ -38,6 +39,7 @@ function AssetUploader({
|
||||
/** Called after a successful upload/delete with the new URL (null when cleared). */
|
||||
onChanged: (newUrl: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -47,7 +49,7 @@ function AssetUploader({
|
||||
const handleFile = async (file: File) => {
|
||||
setError(null);
|
||||
if (file.size > MAX_SIZE[kind]) {
|
||||
setError(`ファイルサイズが上限 ${Math.round(MAX_SIZE[kind] / 1024)}KB を超えています`);
|
||||
setError(t('branding.sizeExceeded', { kb: Math.round(MAX_SIZE[kind] / 1024) }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -60,7 +62,7 @@ function AssetUploader({
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error ?? `アップロードに失敗しました (${res.status})`);
|
||||
throw new Error(body.error ?? t('branding.uploadFailed', { status: res.status }));
|
||||
}
|
||||
onChanged(typeof body.url === 'string' ? body.url : null);
|
||||
} catch (e) {
|
||||
@@ -76,7 +78,7 @@ function AssetUploader({
|
||||
try {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/branding/upload?kind=${kind}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`削除に失敗しました (${res.status})`);
|
||||
if (!res.ok) throw new Error(t('branding.deleteFailed', { status: res.status }));
|
||||
onChanged(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -96,7 +98,7 @@ function AssetUploader({
|
||||
{currentUrl ? (
|
||||
<img src={currentUrl} alt="" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">未設定</span>
|
||||
<span className="text-[10px] text-slate-400">{t('branding.notSet')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -117,7 +119,7 @@ function AssetUploader({
|
||||
disabled={busy}
|
||||
className="px-2.5 h-7 text-2xs font-medium bg-canvas border border-hairline rounded-md text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{currentUrl ? '差し替え' : 'アップロード'}
|
||||
{currentUrl ? t('branding.replace') : t('branding.upload')}
|
||||
</button>
|
||||
{currentUrl && (
|
||||
<button
|
||||
@@ -126,12 +128,12 @@ function AssetUploader({
|
||||
disabled={busy}
|
||||
className="px-2.5 h-7 text-2xs font-medium text-red-700 dark:text-red-300 border border-red-200 bg-canvas hover:bg-red-50 dark:hover:bg-red-500/15 rounded-md disabled:opacity-50 transition-colors"
|
||||
>
|
||||
削除
|
||||
{t('branding.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-1 truncate font-mono">
|
||||
{currentUrl ?? `${ACCEPT[kind]} / 最大 ${Math.round(MAX_SIZE[kind] / 1024)}KB`}
|
||||
{currentUrl ?? t('branding.accept', { accept: ACCEPT[kind], kb: Math.round(MAX_SIZE[kind] / 1024) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,6 +143,7 @@ function AssetUploader({
|
||||
}
|
||||
|
||||
export function BrandingForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const branding = config.branding ?? {};
|
||||
const primaryColor = branding.primaryColor ?? '';
|
||||
const qc = useQueryClient();
|
||||
@@ -155,32 +158,30 @@ export function BrandingForm({ config, onChange }: SectionFormProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Branding</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('branding.title')}</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> の影響を受けません。
|
||||
{t('branding.intro')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<FieldLabel>アプリ名</FieldLabel>
|
||||
<FieldLabel>{t('branding.appName')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.appName ?? ''}
|
||||
onChange={v => onChange('branding.appName', v)}
|
||||
placeholder="MAESTRO"
|
||||
/>
|
||||
<HelpText>TopBar 左上と ブラウザタイトルに表示されます。</HelpText>
|
||||
<HelpText>{t('branding.appNameHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>プライマリカラー</FieldLabel>
|
||||
<FieldLabel>{t('branding.primaryColor')}</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="プライマリカラー"
|
||||
aria-label={t('branding.primaryColor')}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -190,47 +191,47 @@ export function BrandingForm({ config, onChange }: SectionFormProps) {
|
||||
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>
|
||||
<HelpText>{t('branding.primaryColorHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ログイン画面の見出し</FieldLabel>
|
||||
<FieldLabel>{t('branding.loginTitle')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.loginPageTitle ?? ''}
|
||||
onChange={v => onChange('branding.loginPageTitle', v)}
|
||||
placeholder="MAESTRO"
|
||||
/>
|
||||
<HelpText>未設定の場合はアプリ名を使用します。</HelpText>
|
||||
<HelpText>{t('branding.loginTitleHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ロゴ</FieldLabel>
|
||||
<FieldLabel>{t('branding.logo')}</FieldLabel>
|
||||
<AssetUploader
|
||||
kind="logo"
|
||||
currentUrl={branding.logoUrl || null}
|
||||
onChanged={handleAssetChange('logoUrl')}
|
||||
/>
|
||||
<HelpText>TopBar 左上に表示されます。未設定時はデフォルトのアイコンを使用します。</HelpText>
|
||||
<HelpText>{t('branding.logoHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Favicon</FieldLabel>
|
||||
<FieldLabel>{t('branding.favicon')}</FieldLabel>
|
||||
<AssetUploader
|
||||
kind="favicon"
|
||||
currentUrl={branding.faviconUrl || null}
|
||||
onChanged={handleAssetChange('faviconUrl')}
|
||||
/>
|
||||
<HelpText>ブラウザタブに表示されます。</HelpText>
|
||||
<HelpText>{t('branding.faviconHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>フッター文言</FieldLabel>
|
||||
<FieldLabel>{t('branding.footer')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.footerText ?? ''}
|
||||
onChange={v => onChange('branding.footerText', v)}
|
||||
placeholder="© 2026 Your Team"
|
||||
/>
|
||||
<HelpText>画面最下部に小さく表示されます。未設定時は非表示。</HelpText>
|
||||
<HelpText>{t('branding.footerHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
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>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('browser.title')}</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Page Timeout (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.browserPageTimeout ?? 60000}
|
||||
onChange={v => onChange('tools.browserPageTimeout', Number(v))} />
|
||||
<HelpText>ページ読み込みのタイムアウト(ミリ秒)。デフォルト: 60000</HelpText>
|
||||
<HelpText>{t('browser.pageTimeoutHelp')}</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>
|
||||
<HelpText>{t('browser.actionTimeoutHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -34,8 +36,7 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
<option value="msedge">msedge (system Microsoft Edge)</option>
|
||||
</select>
|
||||
<HelpText>
|
||||
Google ログイン等で「セキュアでないブラウザ」と弾かれる場合は <code>chrome</code> に切替。
|
||||
ホストに <code>google-chrome</code> がインストールされている必要あり。
|
||||
{t('browser.channelHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
@@ -43,24 +44,21 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldLabel>Executable Path (optional)</FieldLabel>
|
||||
<FieldInput value={browser.executablePath ?? ''}
|
||||
onChange={v => onChange('browser.executablePath', v || undefined)} />
|
||||
<HelpText>非標準パスにあるブラウザを使う場合のみ指定。未設定なら channel に従う。</HelpText>
|
||||
<HelpText>{t('browser.execPathHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('browser.sessionsTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Browser Session Mode</FieldLabel>
|
||||
<FieldLabel>{t('browser.sessionMode')}</FieldLabel>
|
||||
<select value={browser.displayMode ?? 'headless'}
|
||||
onChange={e => onChange('browser.displayMode', e.target.value)}
|
||||
className="w-full h-9 px-2 text-[13px] border border-hairline rounded-md">
|
||||
<option value="headless">Headless(ライブ表示なし・既定)</option>
|
||||
<option value="novnc">noVNC(Browser/CAPTCHA をライブ表示)</option>
|
||||
<option value="headless">{t('browser.headless')}</option>
|
||||
<option value="novnc">{t('browser.novnc')}</option>
|
||||
</select>
|
||||
<HelpText>
|
||||
ライブ表示機能のマスタースイッチ。<code>novnc</code> にすると
|
||||
<strong>Browser タブのライブ表示・InteractiveBrowse・CAPTCHA 解決</strong>が有効になる。
|
||||
ホストに <code>Xvfb</code> / <code>x11vnc</code> / <code>websockify</code> が必要(未導入なら自動的に headless にフォールバック)。
|
||||
<code>headless</code>(既定)ではそれらの<strong>ライブ表示機能は使えない</strong>。
|
||||
{t('browser.sessionModeHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
@@ -68,35 +66,35 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldLabel>Max CAPTCHA Pages</FieldLabel>
|
||||
<FieldInput type="number" value={browser.maxCaptchaPages ?? 5}
|
||||
onChange={v => onChange('browser.maxCaptchaPages', Number(v))} />
|
||||
<HelpText>CAPTCHA Pool が同時に開けるページ数の上限(<code>novnc</code> モード時のみ有効)。デフォルト: 5</HelpText>
|
||||
<HelpText>{t('browser.maxCaptchaHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>VNC Base Port</FieldLabel>
|
||||
<FieldInput type="number" value={browser.vncBasePort ?? 5900}
|
||||
onChange={v => onChange('browser.vncBasePort', Number(v))} />
|
||||
<HelpText>VNC サーバーのベースポート。デフォルト: 5900</HelpText>
|
||||
<HelpText>{t('browser.vncPortHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Session Data Directory</FieldLabel>
|
||||
<FieldInput value={browser.sessionDataDir ?? './data/browser-sessions'}
|
||||
onChange={v => onChange('browser.sessionDataDir', v)} />
|
||||
<HelpText>Cookie を永続化するディレクトリ。</HelpText>
|
||||
<HelpText>{t('browser.sessionDirHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Sessions</FieldLabel>
|
||||
<FieldInput type="number" value={browser.maxSessions ?? 3}
|
||||
onChange={v => onChange('browser.maxSessions', Number(v))} />
|
||||
<HelpText>同時に起動できるセッションの最大数。デフォルト: 3</HelpText>
|
||||
<HelpText>{t('browser.maxSessionsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Task Session Idle TTL (秒)</FieldLabel>
|
||||
<FieldLabel>{t('browser.idleTtl')}</FieldLabel>
|
||||
<FieldInput type="number" value={browser.taskSessionIdleTtl ?? ''}
|
||||
onChange={v => onChange('browser.taskSessionIdleTtl', v ? Number(v) : undefined)} />
|
||||
<HelpText>タスク用 CDP セッションをアイドル後に破棄するまでの秒数(GC)。デフォルト: 300</HelpText>
|
||||
<HelpText>{t('browser.idleTtlHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useConfig } from '../../hooks/useConfig';
|
||||
import { useUnsavedGuard } from '../../lib/unsavedGuard';
|
||||
@@ -81,6 +82,7 @@ function countDiff(a: any, b: any): number {
|
||||
}
|
||||
|
||||
export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
// 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') {
|
||||
@@ -93,7 +95,7 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
return <div className="max-w-2xl"><MemoryLearningForm /></div>;
|
||||
}
|
||||
if (!isAdmin) {
|
||||
return <div className="max-w-2xl text-sm text-slate-500">この設定は管理者のみ閲覧できます。</div>;
|
||||
return <div className="max-w-2xl text-sm text-slate-500">{t('configForm.adminOnly')}</div>;
|
||||
}
|
||||
// Local organizations: admin-managed via /api/admin/orgs (not config.yaml),
|
||||
// so render stand-alone without the global save bar.
|
||||
@@ -108,6 +110,7 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
}
|
||||
|
||||
function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const { data, isLoading, error, refetch } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -117,6 +120,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [toastIsError, setToastIsError] = useState(false);
|
||||
|
||||
// Sync fetched config into draft
|
||||
useEffect(() => {
|
||||
@@ -146,17 +150,19 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
try {
|
||||
const result = await updateConfig(draft, etag);
|
||||
if (result.conflict) {
|
||||
if (confirm('設定が他で変更されました。再読み込みしますか?')) {
|
||||
if (confirm(t('configForm.conflict'))) {
|
||||
await refetch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
setToast('保存しました');
|
||||
setToastIsError(false);
|
||||
setToast(t('configForm.saved'));
|
||||
setTimeout(() => setToast(null), 2000);
|
||||
} catch (e: any) {
|
||||
setToast(`エラー: ${e.message}`);
|
||||
setToastIsError(true);
|
||||
setToast(t('configForm.error', { msg: e.message }));
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -169,7 +175,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
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 (error) return <div className="text-sm text-red-500">{t('configForm.loadError')}</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
const formProps = { config: draft, onChange: handleChange, overriddenByEnv };
|
||||
@@ -259,15 +265,15 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
}`}
|
||||
>
|
||||
{toast ? (
|
||||
<span className={`text-2xs mr-auto ${toast.startsWith('エラー') ? 'text-red-600' : 'text-emerald-700 dark:text-emerald-300'}`}>
|
||||
<span className={`text-2xs mr-auto ${toastIsError ? 'text-red-600' : 'text-emerald-700 dark:text-emerald-300'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
) : dirty ? (
|
||||
<span className="text-xs mr-auto text-amber-800 dark:text-amber-300 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 className="hidden sm:inline">{t('configForm.unsaved', { count: dirtyCount })}</span>
|
||||
<span className="sm:hidden">{t('configForm.unsavedShort', { count: dirtyCount })}</span>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function ContextForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const ctx = config.context ?? {};
|
||||
const thresholds = ctx.thresholds ?? [
|
||||
{ ratio: 0.7, action: 'warn' },
|
||||
@@ -11,8 +13,8 @@ export function ContextForm({ config, onChange }: SectionFormProps) {
|
||||
];
|
||||
|
||||
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
|
||||
const updated = thresholds.map((th: { ratio: number; action: string }, i: number) =>
|
||||
i === index ? { ...th, [field]: field === 'ratio' ? Number(value) : value } : th
|
||||
);
|
||||
onChange('context.thresholds', updated);
|
||||
};
|
||||
@@ -25,20 +27,20 @@ export function ContextForm({ config, onChange }: SectionFormProps) {
|
||||
<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>
|
||||
placeholder={t('context.limitPlaceholder')} />
|
||||
<HelpText>{t('context.limitHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Thresholds (閾値)</FieldLabel>
|
||||
<FieldLabel>{t('context.thresholdsLabel')}</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{thresholds.map((t: { ratio: number; action: string }, i: number) => (
|
||||
{thresholds.map((th: { 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}
|
||||
value={th.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}
|
||||
<select value={th.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>
|
||||
@@ -49,8 +51,7 @@ export function ContextForm({ config, onChange }: SectionFormProps) {
|
||||
))}
|
||||
</div>
|
||||
<HelpText>
|
||||
コンテキスト使用率に応じたアクション。ratio は 0〜1。
|
||||
warn: ログに警告を出力するのみ / prompt: LLM へ遷移を促すメッセージを注入 / force_transition: default_next に強制遷移
|
||||
{t('context.thresholdsHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -10,10 +11,11 @@ import type { SectionFormProps } from './types';
|
||||
* the underlying config keys keep working without a migration.
|
||||
*/
|
||||
export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Execution</h2>
|
||||
<HelpText>同時実行数、1 ジョブあたりの movement 上限、ジョブ失敗時のリトライ設定。</HelpText>
|
||||
<HelpText>{t('execution.intro')}</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Concurrency</FieldLabel>
|
||||
@@ -22,10 +24,10 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
value={config.concurrency ?? ''}
|
||||
onChange={v => onChange('concurrency', v ? Number(v) : undefined)}
|
||||
disabled={!!overriddenByEnv['concurrency']}
|
||||
disabledReason="CONCURRENCY 環境変数で上書き中"
|
||||
disabledReason={t('execution.concurrencyOverride')}
|
||||
/>
|
||||
{overriddenByEnv['concurrency'] && <EnvOverrideWarning />}
|
||||
<HelpText>同時実行可能なジョブ数</HelpText>
|
||||
<HelpText>{t('execution.concurrencyHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -35,7 +37,7 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
value={config.maxMovements ?? ''}
|
||||
onChange={v => onChange('maxMovements', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>1ジョブあたりの最大 movement 数</HelpText>
|
||||
<HelpText>{t('execution.maxMovementsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Retry</h3>
|
||||
@@ -47,7 +49,7 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
value={config.retry?.maxAttempts ?? 3}
|
||||
onChange={v => onChange('retry.maxAttempts', Number(v))}
|
||||
/>
|
||||
<HelpText>ジョブ失敗時の最大リトライ回数。デフォルト: 3</HelpText>
|
||||
<HelpText>{t('execution.maxAttemptsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -61,7 +63,7 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HelpText>リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900</HelpText>
|
||||
<HelpText>{t('execution.backoffHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CreateInput {
|
||||
team: string;
|
||||
@@ -23,6 +24,7 @@ interface Props {
|
||||
* with the raw bearer; this dialog never displays it.
|
||||
*/
|
||||
export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [team, setTeam] = useState('');
|
||||
const [allowedModelsText, setAllowedModelsText] = useState('');
|
||||
const [tokensBudgetText, setTokensBudgetText] = useState('');
|
||||
@@ -72,7 +74,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-surface 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>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">{t('gateway.createDialog.title')}</h3>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
team <span className="text-red-600">*</span>
|
||||
@@ -88,7 +90,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Allowed models (1 行 / カンマ区切り、空欄=制限なし)
|
||||
{t('gateway.createDialog.allowedModelsLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={allowedModelsText}
|
||||
@@ -108,7 +110,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
|
||||
min="1"
|
||||
value={tokensBudgetText}
|
||||
onChange={(e) => setTokensBudgetText(e.target.value)}
|
||||
placeholder="無制限"
|
||||
placeholder={t('gateway.createDialog.unlimited')}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
@@ -121,7 +123,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
|
||||
min="1"
|
||||
value={rateLimitRpmText}
|
||||
onChange={(e) => setRateLimitRpmText(e.target.value)}
|
||||
placeholder="無制限"
|
||||
placeholder={t('gateway.createDialog.unlimited')}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
@@ -145,7 +147,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
|
||||
disabled={submitting}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '発行中...' : '発行する'}
|
||||
{submitting ? t('gateway.createDialog.issuing') : t('gateway.createDialog.issue')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
rawKey: string;
|
||||
@@ -19,6 +20,7 @@ interface Props {
|
||||
* The dialog is intentionally modal (overlay + focus trap via tabindex).
|
||||
*/
|
||||
export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
|
||||
@@ -66,9 +68,7 @@ export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props)
|
||||
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.',
|
||||
);
|
||||
alert(t('gateway.rawKeyDialog.alertNotSaved'));
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
|
||||
@@ -102,15 +102,14 @@ export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props)
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-surface 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 をローテーションしました'}
|
||||
{reason === 'created' ? t('gateway.rawKeyDialog.titleCreated') : t('gateway.rawKeyDialog.titleRotated')}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">team: {team}</p>
|
||||
|
||||
<div className="rounded border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-500/15 p-3 mb-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-300 font-medium">⚠️ このキーは今後二度と表示されません</p>
|
||||
<p className="text-sm text-red-800 dark:text-red-300 font-medium">{t('gateway.rawKeyDialog.warnTitle')}</p>
|
||||
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
|
||||
必ずパスワードマネージャや LLM クライアントの設定にコピー・保存してから閉じてください。
|
||||
紛失した場合は Rotate で再発行する必要があります。
|
||||
{t('gateway.rawKeyDialog.warnBody')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +141,7 @@ export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props)
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-slate-700">
|
||||
キーを安全に保存しました。今後このキーは表示できなくなることを理解しています。
|
||||
{t('gateway.rawKeyDialog.ackLabel')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getGatewayKeyUsage } from '../../api';
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +20,7 @@ function fmtTokens(n: number): string {
|
||||
* lean.
|
||||
*/
|
||||
export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['gateway-key-usage', keyId],
|
||||
queryFn: () => getGatewayKeyUsage(keyId),
|
||||
@@ -39,7 +41,7 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||||
<div className="bg-surface 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>
|
||||
<h3 className="text-lg font-semibold text-slate-800">{t('gateway.usagePanel.title')}</h3>
|
||||
<p className="text-xs text-slate-500 font-mono">{keyId}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -54,7 +56,7 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||||
|
||||
{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>
|
||||
<div className="text-sm text-red-600">{t('gateway.keys.fetchError', { msg: String((error as Error).message ?? error) })}</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
@@ -63,7 +65,7 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||||
<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})
|
||||
{t('gateway.usagePanel.thisMonth', { period: data.currentPeriod })}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Requests: {data.requestsThisMonth.toLocaleString()}
|
||||
@@ -112,10 +114,10 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||||
{/* 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 か月
|
||||
{t('gateway.usagePanel.past12')}
|
||||
</div>
|
||||
{data.history.length === 0 ? (
|
||||
<div className="text-sm text-slate-400 italic">履歴なし</div>
|
||||
<div className="text-sm text-slate-400 italic">{t('gateway.usagePanel.noHistory')}</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{data.history.map((h) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { GatewayKey } from '../../api';
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
* participate in the surrounding form's draft/dirty/Save&Apply bar.
|
||||
*/
|
||||
export function GatewayKeysSection({ showToast }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
const qc = useQueryClient();
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
@@ -73,7 +75,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
setRawDialog({ rawKey: created.key, team: created.team, reason: 'created' });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Gateway key を発行しました');
|
||||
notify(t('gateway.keys.toast.created'));
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
@@ -82,25 +84,25 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
}
|
||||
|
||||
async function handleRotate(row: GatewayKey): Promise<void> {
|
||||
if (!confirm(`team=${row.team} のキーをローテーションしますか?\n旧キーは無効になります。`)) return;
|
||||
if (!confirm(t('gateway.keys.confirmRotate', { team: row.team }))) 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 しました');
|
||||
notify(t('gateway.keys.toast.rotated'));
|
||||
} 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;
|
||||
if (!confirm(t('gateway.keys.confirmRevoke', { team: row.team, prefix: row.keyPrefix }))) return;
|
||||
try {
|
||||
await revokeGatewayKey(row.id);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Revoke しました');
|
||||
notify(t('gateway.keys.toast.revoked'));
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
@@ -110,7 +112,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
try {
|
||||
await patchGatewayKey(id, patch);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('更新しました');
|
||||
notify(t('gateway.keys.toast.updated'));
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
@@ -175,7 +177,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
onClick={() => { setCreateError(null); setCreating(true); }}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
|
||||
>
|
||||
+ 新規発行
|
||||
{t('gateway.keys.newIssue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -183,12 +185,12 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
{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)}
|
||||
{t('gateway.keys.fetchError', { msg: String((error as Error).message ?? error) })}
|
||||
</div>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<div className="p-6 text-center text-sm text-slate-400">
|
||||
キーが登録されていません。「+ 新規発行」から作成できます。
|
||||
{t('gateway.keys.empty')}
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
@@ -241,7 +243,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
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'}
|
||||
title={isConfig ? t('gateway.keys.titleConfigManaged') : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.tokensBudget !== null ? row.tokensBudget.toLocaleString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
@@ -267,7 +269,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
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'}
|
||||
title={isConfig ? t('gateway.keys.titleConfigManaged') : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.rateLimitRpm !== null ? row.rateLimitRpm.toString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
@@ -294,7 +296,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
onClick={() => setUsagePanelId(row.id)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
詳細
|
||||
{t('gateway.keys.detail')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -323,8 +325,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
Tokens budget は月次 UTC でリセット。Rate limit (rpm) は 60 秒スライディングウィンドウ。
|
||||
config-import のキー(config.yaml から取り込まれたもの)は値の編集ができません。
|
||||
{t('gateway.keys.footer')}
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -170,6 +171,7 @@ function validateBackends(backends: GatewayBackend[]): Map<number, string[]> {
|
||||
}
|
||||
|
||||
export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const gw: GatewayConfigShape = config.gateway ?? {};
|
||||
const backends: GatewayBackend[] = Array.isArray(gw.backends) ? gw.backends : [];
|
||||
|
||||
@@ -210,7 +212,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
<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 プールを共有できます。
|
||||
{t('gateway.server.intro')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
@@ -220,7 +222,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-medium text-slate-700">Enable Gateway</span>
|
||||
<span className="font-medium text-slate-700">{t('gateway.server.enable')}</span>
|
||||
</label>
|
||||
<StatusBadge status={statusQuery.data} />
|
||||
</div>
|
||||
@@ -243,12 +245,11 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={v => setListenPort(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
<strong>同 process 時はこの値は使われません</strong>: worker UI と同じポート (
|
||||
{statusQuery.data?.sharedPort ?? '9876'}) を共有します。<code>AAO_MODE=gateway</code> で別 process 起動した場合のみ有効。
|
||||
{t('gateway.server.listenPortHelp', { port: statusQuery.data?.sharedPort ?? '9876' })}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 pt-1.5">
|
||||
別 process deploy:{' '}
|
||||
{t('gateway.server.separateDeploy')}{' '}
|
||||
<code className="text-2xs">AAO_MODE=gateway scripts/gateway.sh start</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,12 +266,12 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
</button>
|
||||
</div>
|
||||
<HelpText>
|
||||
ルーティング先の llama-server / Ollama / vLLM など。Gateway は worker が送る <strong>role</strong> を担う backend のうち最も空いているものに割り振ります (<code>roles</code> 未設定の backend は全 role 対応)。role を担う backend が無い場合のみ <code>request.model</code> = <code>id</code>/<code>model</code> の厳密一致にフォールバックします。<br/>
|
||||
<strong>api_key の保存形式</strong>: フォームで入力した値は <code>config.yaml</code> に平文で保存されます。<code>${'${VAR}'}</code> 形式の env var 参照はフォーム保存時に literal 文字列として保存されるため、env 経由で渡したい場合は <code>config.yaml</code> を直接編集してください。
|
||||
{t('gateway.server.backendsHelp1')}<br/>
|
||||
{t('gateway.server.backendsHelp2')}
|
||||
</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 つ追加してください。
|
||||
{t('gateway.server.backendsEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mt-2">
|
||||
@@ -284,7 +285,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
<button
|
||||
onClick={() => removeBackend(i)}
|
||||
className="absolute top-1.5 right-2 text-slate-400 hover:text-red-500 text-lg leading-none"
|
||||
title="この backend を削除"
|
||||
title={t('gateway.server.removeBackendTitle')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -311,18 +312,16 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>roles (任意)</FieldLabel>
|
||||
<FieldLabel>{t('gateway.server.rolesLabel')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={rolesToInput(b.roles)}
|
||||
onChange={v => updateBackend(i, 'roles', parseRolesInput(v))}
|
||||
placeholder="quality, auto (空欄=全ロール)"
|
||||
placeholder={t('gateway.server.rolesPlaceholder')}
|
||||
/>
|
||||
<HelpText>
|
||||
この backend が担う性能ティア (<code>auto</code> / <code>fast</code> / <code>quality</code> / <code>reflection</code>) をカンマ区切りで。worker はジョブの role を routing key として送り、Gateway がその role を担う最も空いている backend に振ります。<strong>空欄なら全ロール</strong>を担当 (従来どおり)。異なる model 名の GPU でも同じ role でまとめられます。
|
||||
</HelpText>
|
||||
<HelpText>{t('gateway.server.rolesHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>api_key (任意)</FieldLabel>
|
||||
<FieldLabel>{t('gateway.server.apiKeyLabel')}</FieldLabel>
|
||||
<FieldInput
|
||||
type="password"
|
||||
value={b.apiKey ?? ''}
|
||||
@@ -337,7 +336,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
the env var indirection is lost. */}
|
||||
{typeof b.apiKey === 'string' && b.apiKey.trimStart().startsWith('${') && (
|
||||
<p className="text-2xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded px-2 py-1 mt-1">
|
||||
env var reference detected: 保存すると <code>{b.apiKey}</code> がそのまま config.yaml に書き込まれ、起動時の env 置換は効かなくなります。env 経由で渡すなら config.yaml を直接編集してください。
|
||||
{t('gateway.server.apiKeyEnvWarn', { key: b.apiKey })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -359,8 +358,8 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
<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 ボタンを押す必要はありません)。
|
||||
{t('gateway.server.virtualKeysHelp1')}<br/>
|
||||
{t('gateway.server.virtualKeysHelp2')}
|
||||
</HelpText>
|
||||
<div className="mt-2">
|
||||
<GatewayKeysSection />
|
||||
@@ -379,7 +378,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
value={numberValue(gw.requestTimeoutSec, 600)}
|
||||
onChange={v => setRequestTimeout(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>chat 全体の budget (streaming 含む)</HelpText>
|
||||
<HelpText>{t('gateway.server.advRequestHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>upstream_timeout_sec</FieldLabel>
|
||||
@@ -388,7 +387,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
value={numberValue(gw.upstreamTimeoutSec, 30)}
|
||||
onChange={v => setUpstreamTimeout(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>1 chunk あたりの idle 上限</HelpText>
|
||||
<HelpText>{t('gateway.server.advUpstreamHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>shutdown_graceful_sec</FieldLabel>
|
||||
@@ -397,13 +396,11 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
value={numberValue(gw.shutdownGracefulSec, 30)}
|
||||
onChange={v => setShutdownGraceful(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>SIGTERM 後の drain 上限</HelpText>
|
||||
<HelpText>{t('gateway.server.advShutdownHelp')}</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>
|
||||
<p>{t('gateway.server.hotReload')}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { NamespaceEditor } from './NamespaceEditor';
|
||||
@@ -17,6 +18,7 @@ import type { SectionFormProps } from './types';
|
||||
* but adding new namespaces is disabled in the editor.
|
||||
*/
|
||||
export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
@@ -25,7 +27,7 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
|
||||
<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 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30"
|
||||
title="この機能は legacy です。新規の知識検索統合は MCP server 経由を推奨"
|
||||
title={t('knowledgeDks.legacyBadgeTitle')}
|
||||
>
|
||||
LEGACY
|
||||
</span>
|
||||
@@ -35,16 +37,14 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
|
||||
role="note"
|
||||
className="rounded border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300"
|
||||
>
|
||||
DKS 機能は <strong>legacy</strong> 化されており、新規の知識検索統合は{' '}
|
||||
<strong>MCP server 経由</strong> を推奨します。既存の namespace 設定は引き続き動作しますが、
|
||||
新規 namespace の追加はできません。{' '}
|
||||
{t('knowledgeDks.noteBody')}
|
||||
<a
|
||||
href="/help"
|
||||
className="underline text-amber-900 dark:text-amber-300 hover:text-amber-700 dark:hover:text-amber-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MCP 連携ガイドを開く
|
||||
{t('knowledgeDks.mcpGuideLink')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
|
||||
<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>
|
||||
<HelpText>{t('knowledgeDks.serviceUrlHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -61,10 +61,10 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
|
||||
value={tools.knowledgeNamespaces ?? {}}
|
||||
onChange={v => onChange('tools.knowledgeNamespaces', v)}
|
||||
addDisabled
|
||||
addDisabledReason="新規 namespace 追加は MCP 経由を推奨"
|
||||
addDisabledReason={t('knowledgeDks.addDisabledReason')}
|
||||
addDisabledHref="/help"
|
||||
/>
|
||||
<HelpText>DKS の名前空間と API キーの組み合わせ。既存項目の編集・削除は可能ですが、新規追加は無効化されています。</HelpText>
|
||||
<HelpText>{t('knowledgeDks.namespacesHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import { SecretInput } from './SecretInput';
|
||||
@@ -99,6 +100,7 @@ function detectSelfLoop(endpoint: string | undefined): boolean {
|
||||
* endpoint host looks like the current AAO instance
|
||||
*/
|
||||
export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const llm: LlmConfigShape = config.llm ?? {};
|
||||
const workers: LlmWorker[] = Array.isArray(llm.workers) ? llm.workers : [];
|
||||
const retry = llm.retry ?? {};
|
||||
@@ -144,21 +146,17 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">LLM Workers</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">{t('llmWorkers.title')}</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> (タイトル生成専用)。複数指定可。
|
||||
{t('llmWorkers.intro')}<br />
|
||||
{t('llmWorkers.rolesHelp')}
|
||||
</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 つ追加してください。
|
||||
{t('llmWorkers.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -173,7 +171,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
<button
|
||||
onClick={() => moveWorker(i, -1)}
|
||||
disabled={i === 0}
|
||||
title="上に移動"
|
||||
title={t('llmWorkers.moveUp')}
|
||||
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
|
||||
>
|
||||
↑
|
||||
@@ -181,14 +179,14 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
<button
|
||||
onClick={() => moveWorker(i, 1)}
|
||||
disabled={i === workers.length - 1}
|
||||
title="下に移動"
|
||||
title={t('llmWorkers.moveDown')}
|
||||
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 を削除"
|
||||
title={t('llmWorkers.removeWorker')}
|
||||
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
|
||||
>
|
||||
×
|
||||
@@ -202,7 +200,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Connection type</FieldLabel>
|
||||
<FieldLabel>{t('llmWorkers.connectionType')}</FieldLabel>
|
||||
<select
|
||||
value={w.connectionType ?? (w.proxy === true ? 'aao_gateway' : 'direct')}
|
||||
onChange={e => {
|
||||
@@ -228,7 +226,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
value={w.endpoint ?? ''}
|
||||
onChange={v => updateWorker(i, { endpoint: v })}
|
||||
disabled={!!endpointOverridden}
|
||||
disabledReason="OLLAMA_BASE_URL 環境変数で上書き中"
|
||||
disabledReason={t('llmWorkers.endpointOverride')}
|
||||
placeholder={
|
||||
isGateway
|
||||
? 'http://gateway.example.com:9876/v1'
|
||||
@@ -238,30 +236,20 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
{endpointOverridden && <EnvOverrideWarning />}
|
||||
{showSelfLoop && (
|
||||
<p className="text-2xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded px-2 py-1 mt-1">
|
||||
endpoint は自インスタンスを指しているように見えます (self-loop)。
|
||||
リバースプロキシ越しの場合はこの警告は無視できます。
|
||||
{t('llmWorkers.selfLoopWarn')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>API key{isGateway ? ' (必須)' : ' (任意)'}</FieldLabel>
|
||||
<FieldLabel>{isGateway ? t('llmWorkers.apiKeyRequired') : t('llmWorkers.apiKeyOptional')}</FieldLabel>
|
||||
<SecretInput
|
||||
rawValue={w.apiKey ?? ''}
|
||||
onChange={v => updateWorker(i, { apiKey: v === '' ? '' : v })}
|
||||
placeholder={isGateway ? 'sk-aao-...' : 'sk-... (任意)'}
|
||||
placeholder={isGateway ? 'sk-aao-...' : t('llmWorkers.apiKeyOptionalPlaceholder')}
|
||||
/>
|
||||
<HelpText>
|
||||
{isGateway ? (
|
||||
<>
|
||||
他 AAO の <em>LLM → Gateway Server</em> で発行した{' '}
|
||||
<code>sk-aao-*</code> を貼り付けてください。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。
|
||||
</>
|
||||
)}
|
||||
{isGateway ? t('llmWorkers.apiKeyGatewayHelp') : t('llmWorkers.apiKeyDirectHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
@@ -275,8 +263,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
/>
|
||||
{modelOverridden && <EnvOverrideWarning />}
|
||||
<HelpText>
|
||||
endpoint が <code>/models</code> を返せば dropdown に候補が出ます。
|
||||
出ない場合 (auth が必要、proxy 越し等) は直接入力してください。
|
||||
{t('llmWorkers.modelHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
@@ -290,7 +277,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>最大同時実行数</FieldLabel>
|
||||
<FieldLabel>{t('llmWorkers.maxConcurrency')}</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={w.maxConcurrency ?? 1}
|
||||
@@ -306,11 +293,11 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
onChange={e => updateWorker(i, { enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
有効
|
||||
{t('llmWorkers.enabled')}
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
|
||||
title="VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用"
|
||||
title={t('llmWorkers.vlmTitle')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -330,12 +317,12 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
onClick={addWorker}
|
||||
className="px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft"
|
||||
>
|
||||
+ Worker を追加
|
||||
{t('llmWorkers.addWorker')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Global LLM Settings
|
||||
{t('llmWorkers.globalTitle')}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
@@ -345,11 +332,11 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
value={llm.timeoutMinutes ?? 10}
|
||||
onChange={v => onChange('llm.timeoutMinutes', Number(v))}
|
||||
/>
|
||||
<HelpText>LLM リクエストのタイムアウト (分)。デフォルト: 10</HelpText>
|
||||
<HelpText>{t('llmWorkers.timeoutHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Retry (per-call HTTP)
|
||||
{t('llmWorkers.retryTitle')}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
@@ -359,7 +346,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
value={retry.maxAttempts ?? 3}
|
||||
onChange={v => onChange('llm.retry.maxAttempts', Number(v))}
|
||||
/>
|
||||
<HelpText>1 回の LLM API 呼び出しでの最大試行回数</HelpText>
|
||||
<HelpText>{t('llmWorkers.maxAttemptsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -372,7 +359,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
}}
|
||||
placeholder="2000"
|
||||
/>
|
||||
<HelpText>各リトライ間の待機時間 (ms)。配列順に消費されます。</HelpText>
|
||||
<HelpText>{t('llmWorkers.backoffHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -385,7 +372,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
}}
|
||||
placeholder="429"
|
||||
/>
|
||||
<HelpText>リトライ対象の HTTP ステータスコード。</HelpText>
|
||||
<HelpText>{t('llmWorkers.retryableStatusHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -14,17 +15,17 @@ interface McpRuntimeConfig {
|
||||
}
|
||||
|
||||
export function McpForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
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 を指定してください。
|
||||
{t('mcp.intro')}
|
||||
</p>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">セキュリティ</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">{t('mcp.securityTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
@@ -34,58 +35,57 @@ export function McpForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => onChange('mcp.allowPrivateAddresses', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
プライベート IP への接続を許可する (self-hosted / localhost MCP サーバー用)
|
||||
{t('mcp.allowPrivate')}
|
||||
</label>
|
||||
<HelpText>
|
||||
有効にすると、localhost・LAN アドレス (192.168.x.x, 10.x.x.x 等) への MCP 接続を許可します。
|
||||
SSRF リスクがあるため、信頼できるネットワーク環境でのみ使用してください。デフォルト: 無効
|
||||
{t('mcp.allowPrivateHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">タイムアウト / キャッシュ</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">{t('mcp.timeoutTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール呼び出しタイムアウト (秒)</FieldLabel>
|
||||
<FieldLabel>{t('mcp.callTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.callTimeoutSeconds ?? 60}
|
||||
onChange={v => onChange('mcp.callTimeoutSeconds', Number(v))} />
|
||||
<HelpText>MCP ツールの 1 回の呼び出しに許容する最大時間(秒)。デフォルト: 60</HelpText>
|
||||
<HelpText>{t('mcp.callTimeoutHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール一覧キャッシュ TTL (秒)</FieldLabel>
|
||||
<FieldLabel>{t('mcp.cacheTtl')}</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.toolCacheTtlSeconds ?? 600}
|
||||
onChange={v => onChange('mcp.toolCacheTtlSeconds', Number(v))} />
|
||||
<HelpText>MCP サーバーから取得したツール一覧をキャッシュする時間(秒)。デフォルト: 600</HelpText>
|
||||
<HelpText>{t('mcp.cacheTtlHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>OAuth pending state TTL (分)</FieldLabel>
|
||||
<FieldLabel>{t('mcp.oauthTtl')}</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.oauthPendingTtlMinutes ?? 10}
|
||||
onChange={v => onChange('mcp.oauthPendingTtlMinutes', Number(v))} />
|
||||
<HelpText>MCP OAuth 認可フローの pending 状態を保持する時間(分)。デフォルト: 10</HelpText>
|
||||
<HelpText>{t('mcp.oauthTtlHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">容量制限</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">{t('mcp.capacityTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール出力バイナリ 1 個あたり最大サイズ (MB)</FieldLabel>
|
||||
<FieldLabel>{t('mcp.maxBinary')}</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxBinarySizeMb ?? 20}
|
||||
onChange={v => onChange('mcp.maxBinarySizeMb', Number(v))} />
|
||||
<HelpText>MCP ツールが返すバイナリ出力 1 ファイルの最大サイズ(MB)。デフォルト: 20</HelpText>
|
||||
<HelpText>{t('mcp.maxBinaryHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ジョブあたり最大バイナリファイル数</FieldLabel>
|
||||
<FieldLabel>{t('mcp.maxFiles')}</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxOutputFilesPerJob ?? 10}
|
||||
onChange={v => onChange('mcp.maxOutputFilesPerJob', Number(v))} />
|
||||
<HelpText>1 ジョブで MCP ツールが保存できるバイナリファイルの最大数。デフォルト: 10</HelpText>
|
||||
<HelpText>{t('mcp.maxFilesHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ジョブあたり最大バイナリ合計サイズ (MB)</FieldLabel>
|
||||
<FieldLabel>{t('mcp.maxTotal')}</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxOutputSizeMbPerJob ?? 200}
|
||||
onChange={v => onChange('mcp.maxOutputSizeMbPerJob', Number(v))} />
|
||||
<HelpText>1 ジョブで MCP ツールが保存できるバイナリ出力の合計最大サイズ(MB)。デフォルト: 200</HelpText>
|
||||
<HelpText>{t('mcp.maxTotalHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,13 +67,13 @@ 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})`);
|
||||
if (!res.ok) throw new Error(i18n.t('memoryLearning.err.loadHistory', { ns: 'settings', status: 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})`);
|
||||
if (!res.ok) throw new Error(i18n.t('memoryLearning.err.loadSnapshot', { ns: 'settings', status: res.status }));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -89,22 +91,24 @@ async function revertSnapshot(snapshotId: string): Promise<{ reverted: boolean }
|
||||
|
||||
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})`);
|
||||
if (!res.ok) throw new Error(i18n.t('memoryLearning.err.loadMetrics', { ns: 'settings', status: res.status }));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Shared UI primitives ──────────────────────────────────────────────────────
|
||||
|
||||
const OUTCOME_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
applied: { label: '適用済み', cls: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-800 dark:text-emerald-300' },
|
||||
partial: { label: '一部適用', cls: 'bg-yellow-100 dark:bg-yellow-500/15 text-yellow-800 dark:text-yellow-300' },
|
||||
abstained: { label: '学習なし', cls: 'bg-slate-100 text-slate-600' },
|
||||
rejected: { label: '却下', cls: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-300' },
|
||||
failed: { label: '失敗', cls: 'bg-red-200 dark:bg-red-500/20 text-red-900 dark:text-red-300' },
|
||||
const OUTCOME_CLS: Record<string, string> = {
|
||||
applied: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-800 dark:text-emerald-300',
|
||||
partial: 'bg-yellow-100 dark:bg-yellow-500/15 text-yellow-800 dark:text-yellow-300',
|
||||
abstained: 'bg-slate-100 text-slate-600',
|
||||
rejected: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-300',
|
||||
failed: 'bg-red-200 dark:bg-red-500/20 text-red-900 dark:text-red-300',
|
||||
};
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: string }) {
|
||||
const { label, cls } = OUTCOME_LABELS[outcome] ?? { label: outcome, cls: 'bg-slate-100 text-slate-600' };
|
||||
const { t } = useTranslation('settings');
|
||||
const cls = OUTCOME_CLS[outcome] ?? 'bg-slate-100 text-slate-600';
|
||||
const label = OUTCOME_CLS[outcome] ? t(`memoryLearning.outcome.${outcome}`) : outcome;
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${cls}`}>
|
||||
{label}
|
||||
@@ -126,6 +130,7 @@ function formatTs(ts: string): string {
|
||||
// ── SnapshotCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onReverted: () => void }) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [confirmRevert, setConfirmRevert] = useState(false);
|
||||
const [revertDone, setRevertDone] = useState<boolean | null>(null);
|
||||
@@ -173,7 +178,7 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
)}
|
||||
{item.reverted && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded">
|
||||
revert済み
|
||||
{t('memoryLearning.snapshot.reverted')}
|
||||
</span>
|
||||
)}
|
||||
{detailQuery.data && <OutcomeBadge outcome={detailQuery.data.outcome} />}
|
||||
@@ -185,11 +190,11 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
{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>
|
||||
<div className="text-xs text-slate-400">{t('memoryLearning.snapshot.loadingDetail')}</div>
|
||||
)}
|
||||
{detailQuery.error && (
|
||||
<div className="text-xs text-red-600">
|
||||
読み込みに失敗しました: {String(detailQuery.error)}
|
||||
{t('memoryLearning.snapshot.detailLoadError', { err: String(detailQuery.error) })}
|
||||
</div>
|
||||
)}
|
||||
{detailQuery.data && (() => {
|
||||
@@ -211,7 +216,7 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
{d.reasoning && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
推論
|
||||
{t('memoryLearning.snapshot.reasoning')}
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 whitespace-pre-wrap">{d.reasoning}</p>
|
||||
</div>
|
||||
@@ -220,12 +225,13 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
{d.rejections && d.rejections.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
却下理由
|
||||
{t('memoryLearning.snapshot.rejectionsTitle')}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{d.rejections.map((r, i) => (
|
||||
<li key={i} className="text-2xs text-red-700 dark:text-red-300">
|
||||
<span className="font-mono">{r.code}</span>
|
||||
{/* 既知コードは翻訳ラベル、未知コードは生 ID にフォールバック (#469) */}
|
||||
<span>{t(`memoryLearning.rejection.${r.code}`, { defaultValue: r.code })}</span>
|
||||
{r.name && <span className="text-slate-500 ml-1">({r.name})</span>}
|
||||
</li>
|
||||
))}
|
||||
@@ -236,7 +242,7 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
{d.diff && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
変更内容
|
||||
{t('memoryLearning.snapshot.changes')}
|
||||
</div>
|
||||
<pre className="text-2xs text-slate-700 bg-canvas border border-hairline rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap">
|
||||
{d.diff}
|
||||
@@ -253,17 +259,17 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
{d.pieceEdited && d.pieceBeforeYaml && d.pieceAfterYaml && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
Piece の差分
|
||||
{t('memoryLearning.snapshot.pieceDiff')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更前</div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.before')}</div>
|
||||
<pre className="text-[10px] bg-canvas 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>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.after')}</div>
|
||||
<pre className="text-[10px] bg-canvas border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{d.pieceAfterYaml}
|
||||
</pre>
|
||||
@@ -276,10 +282,10 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
{!item.reverted && (
|
||||
<div className="pt-1">
|
||||
{revertDone === true && (
|
||||
<span className="text-xs text-emerald-700 dark:text-emerald-300">正常に revert しました。</span>
|
||||
<span className="text-xs text-emerald-700 dark:text-emerald-300">{t('memoryLearning.snapshot.revertedOk')}</span>
|
||||
)}
|
||||
{revertDone === false && (
|
||||
<span className="text-xs text-slate-500">すでに revert 済みです。</span>
|
||||
<span className="text-xs text-slate-500">{t('memoryLearning.snapshot.alreadyReverted')}</span>
|
||||
)}
|
||||
{revertDone === null && !confirmRevert && (
|
||||
<button
|
||||
@@ -287,13 +293,13 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
onClick={() => setConfirmRevert(true)}
|
||||
className="px-2.5 h-7 text-2xs text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 hover:bg-amber-100 dark:hover:bg-amber-500/15 rounded transition-colors"
|
||||
>
|
||||
このスナップショットを revert…
|
||||
{t('memoryLearning.snapshot.revertBtn')}
|
||||
</button>
|
||||
)}
|
||||
{revertDone === null && confirmRevert && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-amber-800 dark:text-amber-300">
|
||||
このスナップショットの変更前の状態に戻しますか?
|
||||
{t('memoryLearning.snapshot.revertConfirmQ')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -301,14 +307,14 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
|
||||
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 を確定'}
|
||||
{revertMutation.isPending ? t('memoryLearning.snapshot.reverting') : t('memoryLearning.snapshot.revertConfirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRevert(false)}
|
||||
className="px-2.5 h-7 text-2xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
{t('memoryLearning.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -337,6 +343,7 @@ function BeforeAfterDiff({
|
||||
beforeFiles: Record<string, string>;
|
||||
afterFiles: Record<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const allNames = Array.from(
|
||||
new Set([...Object.keys(beforeFiles), ...Object.keys(afterFiles)]),
|
||||
).sort();
|
||||
@@ -353,7 +360,7 @@ function BeforeAfterDiff({
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
メモリファイルの差分
|
||||
{t('memoryLearning.diff.title')}
|
||||
</div>
|
||||
{allNames.length > 1 && (
|
||||
<div className="flex gap-1 mb-2 flex-wrap">
|
||||
@@ -375,28 +382,28 @@ function BeforeAfterDiff({
|
||||
)}
|
||||
{isAdded && (
|
||||
<div className="text-2xs text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-500/15 border border-emerald-200 dark:border-emerald-500/30 rounded px-2 py-1 mb-1">
|
||||
追加
|
||||
{t('memoryLearning.diff.added')}
|
||||
</div>
|
||||
)}
|
||||
{isRemoved && (
|
||||
<div className="text-2xs text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded px-2 py-1 mb-1">
|
||||
削除
|
||||
{t('memoryLearning.diff.removed')}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{!isAdded && (
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更前</div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.before')}</div>
|
||||
<pre className="text-[10px] bg-canvas border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{before ?? '(空)'}
|
||||
{before ?? t('memoryLearning.empty')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!isRemoved && (
|
||||
<div className={isAdded ? 'col-span-2' : ''}>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更後</div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.after')}</div>
|
||||
<pre className="text-[10px] bg-canvas border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{after ?? '(空)'}
|
||||
{after ?? t('memoryLearning.empty')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -408,6 +415,7 @@ function BeforeAfterDiff({
|
||||
// ── MetricsSummary ────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricsSummary() {
|
||||
const { t } = useTranslation('settings');
|
||||
const { data, isLoading, error } = useQuery<ReflectionMetrics>({
|
||||
queryKey: ['reflection-metrics', 30],
|
||||
queryFn: () => fetchMetrics(30),
|
||||
@@ -415,13 +423,13 @@ function MetricsSummary() {
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-xs text-slate-400 px-4 py-3">メトリクスを読み込み中…</div>;
|
||||
return <div className="text-xs text-slate-400 px-4 py-3">{t('memoryLearning.metrics.loading')}</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-xs text-red-600 px-4 py-3">
|
||||
メトリクスの読み込みに失敗しました: {String(error)}
|
||||
{t('memoryLearning.metrics.loadError', { err: String(error) })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -436,15 +444,15 @@ function MetricsSummary() {
|
||||
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日間のサマリ
|
||||
{t('memoryLearning.metrics.summary30')}
|
||||
</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) },
|
||||
{ label: t('memoryLearning.metrics.totalRuns'), value: String(totalRuns) },
|
||||
{ label: t('memoryLearning.metrics.appliedRate'), value: `${appliedPct}%` },
|
||||
{ label: t('memoryLearning.metrics.abstainRate'), value: `${abstainPct}%` },
|
||||
{ label: t('memoryLearning.metrics.tokens'), value: totalTokens > 1000 ? `${Math.round(totalTokens / 1000)}k` : String(totalTokens) },
|
||||
{ label: t('memoryLearning.metrics.pieceEdits'), value: String(data.pieceEdits) },
|
||||
].map(({ label, value }) => (
|
||||
<div
|
||||
key={label}
|
||||
@@ -457,7 +465,7 @@ function MetricsSummary() {
|
||||
</div>
|
||||
{totalRuns === 0 && (
|
||||
<p className="text-2xs text-slate-400 mt-2">
|
||||
まだ reflection の実行履歴がありません。最初の reflection が完了するとメトリクスが表示されます。
|
||||
{t('memoryLearning.metrics.noRuns')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -467,14 +475,15 @@ function MetricsSummary() {
|
||||
// ── ReflectionTimelinePanel ───────────────────────────────────────────────────
|
||||
|
||||
const OUTCOME_FILTER_OPTIONS = [
|
||||
{ value: 'applied', label: '適用済み' },
|
||||
{ value: 'partial', label: '一部適用' },
|
||||
{ value: 'abstained', label: '学習なし' },
|
||||
{ value: 'rejected', label: '却下' },
|
||||
{ value: 'failed', label: '失敗' },
|
||||
{ value: 'applied' },
|
||||
{ value: 'partial' },
|
||||
{ value: 'abstained' },
|
||||
{ value: 'rejected' },
|
||||
{ value: 'failed' },
|
||||
];
|
||||
|
||||
function ReflectionTimelinePanel() {
|
||||
const { t } = useTranslation('settings');
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Filters (client-side — the backend doesn't support filtering natively)
|
||||
@@ -513,9 +522,9 @@ function ReflectionTimelinePanel() {
|
||||
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>
|
||||
<h3 className="text-sm font-semibold text-slate-800">{t('memoryLearning.timeline.title')}</h3>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
reflection 実行の履歴。各行を展開すると推論・変更前後の差分・revert コントロールを確認できます。
|
||||
{t('memoryLearning.timeline.subtitle')}
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -527,11 +536,11 @@ function ReflectionTimelinePanel() {
|
||||
onChange={e => setIncludeReverted(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
revert 済みを表示
|
||||
{t('memoryLearning.timeline.showReverted')}
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-2xs text-slate-500">結果:</span>
|
||||
<span className="text-2xs text-slate-500">{t('memoryLearning.timeline.resultLabel')}</span>
|
||||
{OUTCOME_FILTER_OPTIONS.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-1 text-2xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
@@ -552,7 +561,7 @@ function ReflectionTimelinePanel() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
{t(`memoryLearning.outcome.${opt.value}`)}
|
||||
</label>
|
||||
))}
|
||||
{outcomeFilter.length > 0 && (
|
||||
@@ -561,7 +570,7 @@ function ReflectionTimelinePanel() {
|
||||
onClick={() => setOutcomeFilter([])}
|
||||
className="text-[10px] text-accent underline"
|
||||
>
|
||||
リセット
|
||||
{t('memoryLearning.timeline.reset')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -570,20 +579,20 @@ function ReflectionTimelinePanel() {
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
{isLoading && (
|
||||
<div className="text-xs text-slate-400 text-center py-4">読み込み中…</div>
|
||||
<div className="text-xs text-slate-400 text-center py-4">{t('memoryLearning.loading')}</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-600 px-2">
|
||||
読み込みに失敗しました: {String(error)}
|
||||
{t('memoryLearning.timeline.loadError', { err: 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-xs text-slate-400">{t('memoryLearning.timeline.emptyTitle')}</p>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
タスク完了後に自動で reflection が実行されます。
|
||||
{t('memoryLearning.timeline.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -600,7 +609,7 @@ function ReflectionTimelinePanel() {
|
||||
disabled={isFetchingNextPage}
|
||||
className="px-3 h-8 text-xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded-md disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isFetchingNextPage ? '読み込み中…' : 'さらに表示'}
|
||||
{isFetchingNextPage ? t('memoryLearning.loading') : t('memoryLearning.timeline.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -614,12 +623,12 @@ function ReflectionTimelinePanel() {
|
||||
// ── MemoryLearningForm (root export) ──────────────────────────────────────────
|
||||
|
||||
export function MemoryLearningForm() {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-slate-800">Reflection 履歴</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('memoryLearning.rootTitle')}</h2>
|
||||
<p className="text-xs text-slate-500 -mt-4">
|
||||
自動学習(reflection)の実行履歴・差分・revert を確認できます。
|
||||
メモリエントリの閲覧・編集は ユーザーフォルダ → memory/ へ移動しました。
|
||||
{t('memoryLearning.rootIntro')}
|
||||
</p>
|
||||
|
||||
<ReflectionTimelinePanel />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
@@ -27,6 +28,7 @@ function MetricsBlock({
|
||||
config: any;
|
||||
onChange: (path: string, value: any) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const root = path.split('.').reduce((acc: any, key) => (acc ?? {})[key], config) ?? {};
|
||||
return (
|
||||
<section className="space-y-4 border border-hairline rounded-md p-4">
|
||||
@@ -39,7 +41,7 @@ function MetricsBlock({
|
||||
checked={root.enabled === true}
|
||||
onChange={e => onChange(`${path}.enabled`, e.target.checked)}
|
||||
/>
|
||||
<span>有効化</span>
|
||||
<span>{t('metrics.enable')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +52,7 @@ function MetricsBlock({
|
||||
onChange={v => onChange(`${path}.prefix`, v || undefined)}
|
||||
placeholder={prefixDefault}
|
||||
/>
|
||||
<HelpText>Prometheus metric 名の prefix(例: <code>{prefixDefault}</code>)</HelpText>
|
||||
<HelpText>{t('metrics.prefixHelp', { prefix: prefixDefault })}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -62,8 +64,7 @@ function MetricsBlock({
|
||||
placeholder="env:METRICS_BEARER_TOKEN"
|
||||
/>
|
||||
<HelpText>
|
||||
<code>/metrics</code> エンドポイントへのアクセス時に要求される Bearer token。
|
||||
<code>env:NAME</code> で環境変数参照可。
|
||||
{t('metrics.bearerHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
@@ -74,20 +75,19 @@ function MetricsBlock({
|
||||
onChange={v => onChange(`${path}.allowedHosts`, v)}
|
||||
placeholder="127.0.0.1 / ::1 / localhost"
|
||||
/>
|
||||
<HelpText>許可するクライアント host (IP / hostname)。空の場合は token のみで認証。</HelpText>
|
||||
<HelpText>{t('metrics.allowedHostsHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
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> に分離されています。
|
||||
{t('metrics.intro')}
|
||||
</HelpText>
|
||||
|
||||
<MetricsBlock
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MovementForm } from './MovementForm';
|
||||
|
||||
export interface MovementAccordionProps {
|
||||
@@ -11,6 +12,7 @@ export interface MovementAccordionProps {
|
||||
}
|
||||
|
||||
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove, disabled = false }: MovementAccordionProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const movementNames = movements.map((m) => m.name ?? '');
|
||||
|
||||
@@ -74,7 +76,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
|
||||
if (confirm(t('movementAccordion.confirmDelete', { name: movement.name }))) {
|
||||
onRemove(i);
|
||||
if (expandedIndex === i) setExpandedIndex(null);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { ToolTagInput } from './ToolTagInput';
|
||||
import { RulesTable } from './RulesTable';
|
||||
@@ -12,6 +13,7 @@ export interface MovementFormProps {
|
||||
}
|
||||
|
||||
export function MovementForm({ movement, movementNames, onChange, disabled = false }: MovementFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
|
||||
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||
|
||||
@@ -67,7 +69,7 @@ export function MovementForm({ movement, movementNames, onChange, disabled = fal
|
||||
className="rounded border-slate-300 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
|
||||
<HelpText>有効にすると Write / Edit ツールが LLM に提示されます</HelpText>
|
||||
<HelpText>{t('movement.editHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* instruction */}
|
||||
@@ -80,7 +82,7 @@ export function MovementForm({ movement, movementNames, onChange, disabled = fal
|
||||
disabled={disabled}
|
||||
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 ${disabledClass}`}
|
||||
/>
|
||||
<HelpText>LLM に渡される指示文。Markdown 記法が使えます</HelpText>
|
||||
<HelpText>{t('movement.instructionHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* allowed_tools */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface NamespaceEditorProps {
|
||||
value: Record<string, { apiKey: string }>;
|
||||
@@ -22,6 +23,7 @@ export function NamespaceEditor({
|
||||
addDisabledReason,
|
||||
addDisabledHref,
|
||||
}: NamespaceEditorProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newApiKey, setNewApiKey] = useState('');
|
||||
|
||||
@@ -90,8 +92,8 @@ export function NamespaceEditor({
|
||||
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>
|
||||
aria-label={addDisabled ? t('namespaceEditor.addAriaDisabled') : t('namespaceEditor.addAria')}
|
||||
>{t('namespaceEditor.add')}</button>
|
||||
{addDisabled && addDisabledHref && (
|
||||
<a
|
||||
href={addDisabledHref}
|
||||
@@ -99,7 +101,7 @@ export function NamespaceEditor({
|
||||
rel="noopener noreferrer"
|
||||
className="px-2 py-1.5 text-xs text-accent underline self-center"
|
||||
title={disabledTitle}
|
||||
>MCP ガイド</a>
|
||||
>{t('namespaceEditor.mcpGuide')}</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -7,41 +8,42 @@ import type { SectionFormProps } from './types';
|
||||
* of a subscribed note is injected into the agent's context per job.
|
||||
*/
|
||||
export function NotesForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const inject = config.notes?.inject ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Notes Injection</h2>
|
||||
<p className="text-[13px] text-slate-500">
|
||||
購読済みの共有ナレッジノートを、ジョブ実行時にエージェントの context へ注入する際の予算。
|
||||
{t('notes.intro')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Per-Note Max (KB)</FieldLabel>
|
||||
<FieldInput type="number" value={inject.perNoteMaxKb ?? ''}
|
||||
onChange={v => onChange('notes.inject.perNoteMaxKb', v ? Number(v) : undefined)} />
|
||||
<HelpText>1 ノートあたり注入する最大サイズ。デフォルト: 8 KB</HelpText>
|
||||
<HelpText>{t('notes.perNoteHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Total Max (KB)</FieldLabel>
|
||||
<FieldInput type="number" value={inject.totalMaxKb ?? ''}
|
||||
onChange={v => onChange('notes.inject.totalMaxKb', v ? Number(v) : undefined)} />
|
||||
<HelpText>全ノート合算で注入する最大サイズ。デフォルト: 32 KB</HelpText>
|
||||
<HelpText>{t('notes.totalHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Over-Budget Strategy</FieldLabel>
|
||||
<FieldLabel>{t('notes.overBudgetLabel')}</FieldLabel>
|
||||
<select
|
||||
value={inject.overBudgetStrategy ?? 'skip_remaining'}
|
||||
onChange={e => onChange('notes.inject.overBudgetStrategy', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
|
||||
>
|
||||
<option value="skip_remaining">skip_remaining(予算超過後のノートは注入しない)</option>
|
||||
<option value="truncate_last">truncate_last(最後のノートを途中で切り詰める)</option>
|
||||
<option value="degrade_to_search">degrade_to_search(注入せず検索ツールに委ねる)</option>
|
||||
<option value="skip_remaining">{t('notes.skipRemaining')}</option>
|
||||
<option value="truncate_last">{t('notes.truncateLast')}</option>
|
||||
<option value="degrade_to_search">{t('notes.degradeToSearch')}</option>
|
||||
</select>
|
||||
<HelpText>合計予算を超えたときの挙動。デフォルト: skip_remaining</HelpText>
|
||||
<HelpText>{t('notes.overBudgetHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import {
|
||||
isNotificationSupported,
|
||||
@@ -32,21 +33,16 @@ import {
|
||||
} 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)' },
|
||||
];
|
||||
const EVENT_KEYS: NotifyEventType[] = ['running', 'succeeded', 'failed', 'waiting_human'];
|
||||
|
||||
type PushAvailability =
|
||||
| { kind: 'supported' }
|
||||
| { kind: 'needs-pwa-ios' }
|
||||
| { kind: 'unsupported'; reason: string };
|
||||
| { kind: 'unsupported' };
|
||||
|
||||
function evaluatePushAvailability(): PushAvailability {
|
||||
if (!isPushSupported()) {
|
||||
return { kind: 'unsupported', reason: 'お使いのブラウザは Web Push API に対応していません' };
|
||||
return { kind: 'unsupported' };
|
||||
}
|
||||
if (isIOS() && !isStandalonePWA()) {
|
||||
return { kind: 'needs-pwa-ios' };
|
||||
@@ -55,6 +51,7 @@ function evaluatePushAvailability(): PushAvailability {
|
||||
}
|
||||
|
||||
export function NotificationsForm() {
|
||||
const { t } = useTranslation('settings');
|
||||
const supported = isNotificationSupported();
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
|
||||
getNotificationPermission(),
|
||||
@@ -179,8 +176,8 @@ export function NotificationsForm() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">ブラウザ通知</h3>
|
||||
<HelpText>お使いのブラウザは Notification API に未対応です。</HelpText>
|
||||
<h3 className="text-sm font-bold text-slate-900">{t('notifications.v1UnsupportedTitle')}</h3>
|
||||
<HelpText>{t('notifications.v1Unsupported')}</HelpText>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -195,7 +192,7 @@ export function NotificationsForm() {
|
||||
|
||||
const handleTestV1 = () => {
|
||||
const opts = buildNotificationOptions(
|
||||
{ id: 0, title: 'テスト通知', pieceName: 'ブラウザ通知は正常に動作しています' },
|
||||
{ id: 0, title: t('notifications.testTitle'), pieceName: t('notifications.testBody') },
|
||||
'succeeded',
|
||||
);
|
||||
createNotification(opts, () => { /* no-op */ });
|
||||
@@ -263,31 +260,31 @@ export function NotificationsForm() {
|
||||
};
|
||||
|
||||
const v1StatusBadge = (() => {
|
||||
if (permission === 'granted' && enabled) return '✅ 有効化済み';
|
||||
if (permission === 'granted' && !enabled) return '⏸ 一時停止中';
|
||||
if (permission === 'denied') return '🚫 ブラウザで拒否';
|
||||
return '❌ 未許可';
|
||||
if (permission === 'granted' && enabled) return t('notifications.status.enabled');
|
||||
if (permission === 'granted' && !enabled) return t('notifications.status.paused');
|
||||
if (permission === 'denied') return t('notifications.status.denied');
|
||||
return t('notifications.status.notAllowed');
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── V1: 前面通知 ── */}
|
||||
{/* ── V1: foreground notifications ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">ブラウザ通知 (V1: 前面表示)</h3>
|
||||
<p className="mt-1 text-[13px] text-slate-700">状態: {v1StatusBadge}</p>
|
||||
<h3 className="text-sm font-bold text-slate-900">{t('notifications.v1Title')}</h3>
|
||||
<p className="mt-1 text-[13px] text-slate-700">{t('notifications.statusLabel', { status: v1StatusBadge })}</p>
|
||||
|
||||
{permission === 'default' && (
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="mt-2 px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px]"
|
||||
>
|
||||
ブラウザ通知を有効化
|
||||
{t('notifications.enableButton')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<HelpText>
|
||||
ブラウザのアドレスバー左の設定アイコンから「通知」を許可に変更してください。
|
||||
{t('notifications.deniedHelp')}
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
@@ -298,7 +295,7 @@ export function NotificationsForm() {
|
||||
checked={enabled}
|
||||
onChange={e => void setEnabled(e.target.checked)}
|
||||
/>
|
||||
通知を受け取る (マスター ON/OFF)
|
||||
{t('notifications.masterToggle')}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -308,31 +305,30 @@ export function NotificationsForm() {
|
||||
disabled={!enabled}
|
||||
className="mt-2 px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
テスト通知 (ページ内)
|
||||
{t('notifications.testV1')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── V2: モバイル / バックグラウンド通知 ── */}
|
||||
{/* ── V2: mobile / background notifications ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">📱 モバイル / バックグラウンド通知 (V2)</h3>
|
||||
<h3 className="text-sm font-bold text-slate-900">{t('notifications.v2Title')}</h3>
|
||||
|
||||
{pushAvailable.kind === 'unsupported' && (
|
||||
<HelpText>{pushAvailable.reason}</HelpText>
|
||||
<HelpText>{t('notifications.pushUnsupported')}</HelpText>
|
||||
)}
|
||||
|
||||
{pushAvailable.kind === 'needs-pwa-ios' && (
|
||||
<HelpText>
|
||||
iOS Safari では「共有 → ホーム画面に追加」でアプリとしてインストールしてから、
|
||||
ホーム画面のアイコンから開いた状態で通知を有効化できます。
|
||||
{t('notifications.iosPwaHelp')}
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
{pushAvailable.kind === 'supported' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-[13px] text-slate-700">
|
||||
状態: {hasLocalSubscription ? '✅ このデバイスで購読中' : '❌ このデバイスは未購読'}
|
||||
{subscriptions.length > 0 && ` (合計 ${subscriptions.length} デバイス)`}
|
||||
{hasLocalSubscription ? t('notifications.deviceSubscribed') : t('notifications.deviceNotSubscribed')}
|
||||
{subscriptions.length > 0 && t('notifications.deviceCount', { count: subscriptions.length })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -340,7 +336,7 @@ export function NotificationsForm() {
|
||||
disabled={busy || hasLocalSubscription || !enabled}
|
||||
className="px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px] disabled:opacity-50"
|
||||
>
|
||||
このデバイスで購読
|
||||
{t('notifications.subscribe')}
|
||||
</button>
|
||||
{hasLocalSubscription && (
|
||||
<button
|
||||
@@ -348,7 +344,7 @@ export function NotificationsForm() {
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
購読を解除
|
||||
{t('notifications.unsubscribe')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -356,23 +352,23 @@ export function NotificationsForm() {
|
||||
disabled={busy || subscriptions.length === 0}
|
||||
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
テスト通知 (サーバー経由)
|
||||
{t('notifications.testV2')}
|
||||
</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">
|
||||
購読デバイス一覧
|
||||
{t('notifications.deviceListTitle')}
|
||||
</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="truncate max-w-md">{s.userAgent ?? t('notifications.unknownDevice')}</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>
|
||||
<span className="ml-2 text-red-600">{t('notifications.failures', { count: s.failureCount })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +377,7 @@ export function NotificationsForm() {
|
||||
disabled={busy}
|
||||
className="ml-2 px-2 py-1 text-[11px] text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 rounded"
|
||||
>
|
||||
解除
|
||||
{t('notifications.removeDevice')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -395,25 +391,25 @@ export function NotificationsForm() {
|
||||
checked={includeDetails}
|
||||
onChange={e => void setIncludeDetails(e.target.checked)}
|
||||
/>
|
||||
通知にタスクの詳細(タイトル・piece 名)を含める
|
||||
{t('notifications.includeDetails')}
|
||||
<span className="text-[11px] text-slate-500">
|
||||
(OFF: 「タスク #N 完了」のみ)
|
||||
{t('notifications.includeDetailsHint')}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{pushFatal && (
|
||||
<p className="text-[12px] text-red-700 dark:text-red-300">エラー: {pushFatal}</p>
|
||||
<p className="text-[12px] text-red-700 dark:text-red-300">{t('notifications.error', { msg: pushFatal })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 通知するイベント (V1 + V2 共通) ── */}
|
||||
{/* ── events to notify (V1 + V2 shared) ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">通知するイベント</h3>
|
||||
<h3 className="text-sm font-bold text-slate-900">{t('notifications.eventsTitle')}</h3>
|
||||
<div className="mt-2 space-y-1">
|
||||
{EVENT_LABELS.map(({ key, label }) => (
|
||||
{EVENT_KEYS.map(key => (
|
||||
<label key={key} className="flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -421,16 +417,16 @@ export function NotificationsForm() {
|
||||
onChange={() => void toggleEvent(key)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
{label}
|
||||
{t(`notifications.events.${key}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HelpText>
|
||||
ⓘ V1 (前面表示) はタブが開いていてフォーカスがある時のみ動作します<br />
|
||||
ⓘ V2 (モバイル / バックグラウンド) は HTTPS + PWA インストール時のみ確実に動作します<br />
|
||||
ⓘ 自分が owner のタスクのみ通知されます
|
||||
{t('notifications.footer1')}<br />
|
||||
{t('notifications.footer2')}<br />
|
||||
{t('notifications.footer3')}
|
||||
</HelpText>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
@@ -20,6 +21,7 @@ async function jget<T>(url: string): Promise<T> {
|
||||
}
|
||||
|
||||
export function OrgsForm() {
|
||||
const { t } = useTranslation('settings');
|
||||
const qc = useQueryClient();
|
||||
const orgsQ = useQuery<LocalOrg[]>({
|
||||
queryKey: ['admin', 'orgs'],
|
||||
@@ -80,8 +82,7 @@ export function OrgsForm() {
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">Organizations</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
ローカルアカウント向けの組織。タスク/スケジュールの可視性を <code>org</code> にして、
|
||||
同じ組織のメンバーと共有できます(Gitea 組織とは別系統)。
|
||||
{t('orgs.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -92,18 +93,18 @@ export function OrgsForm() {
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="新しい組織名"
|
||||
placeholder={t('orgs.newNamePlaceholder')}
|
||||
className="flex-1 h-9 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
|
||||
/>
|
||||
<button type="submit" disabled={!newName.trim() || createMut.isPending} className="px-3 h-9 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90 whitespace-nowrap">
|
||||
+ 組織を作成
|
||||
{t('orgs.create')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{orgsQ.isLoading && <div className="text-xs text-slate-500">読み込み中...</div>}
|
||||
{orgsQ.isLoading && <div className="text-xs text-slate-500">{t('orgs.loading')}</div>}
|
||||
{!orgsQ.isLoading && orgs.length === 0 && (
|
||||
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 text-center">
|
||||
組織がありません。上で作成してください。
|
||||
{t('orgs.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -115,13 +116,13 @@ export function OrgsForm() {
|
||||
users={users}
|
||||
userLabel={userLabel}
|
||||
onRename={(name) => renameMut.mutate({ id: org.id, name })}
|
||||
onDelete={() => { if (confirm(`組織「${org.name}」を削除しますか?\nこの組織に共有されているタスク等は private に戻ります。`)) deleteMut.mutate(org.id); }}
|
||||
onDelete={() => { if (confirm(t('orgs.confirmDelete', { name: org.name }))) deleteMut.mutate(org.id); }}
|
||||
onAddMember={(userId) => addMemberMut.mutate({ id: org.id, userId })}
|
||||
onRemoveMember={(userId) => removeMemberMut.mutate({ id: org.id, userId })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>メンバーを追加/削除すると、そのユーザーのセッションは無効化され、次回アクセス時に共有が反映されます。</HelpText>
|
||||
<HelpText>{t('orgs.memberHint')}</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,6 +136,7 @@ function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRem
|
||||
onAddMember: (userId: string) => void;
|
||||
onRemoveMember: (userId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [name, setName] = useState(org.name);
|
||||
const memberIds = new Set(org.members.map(m => m.userId));
|
||||
const addable = users.filter(u => !memberIds.has(u.id));
|
||||
@@ -150,25 +152,25 @@ function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRem
|
||||
className="flex-1 h-8 px-2 text-[13px] font-medium border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
|
||||
/>
|
||||
<button type="button" onClick={onDelete} className="px-2.5 h-8 rounded-md text-xs font-medium border border-red-200 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 whitespace-nowrap">
|
||||
削除
|
||||
{t('orgs.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-2xs text-slate-500 mb-1.5">メンバー({org.members.length})</div>
|
||||
<div className="text-2xs text-slate-500 mb-1.5">{t('orgs.membersCount', { count: org.members.length })}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2.5">
|
||||
{org.members.length === 0 && <span className="text-2xs text-slate-400">メンバーがいません</span>}
|
||||
{org.members.length === 0 && <span className="text-2xs text-slate-400">{t('orgs.noMembers')}</span>}
|
||||
{org.members.map(m => (
|
||||
<span key={m.userId} className="inline-flex items-center gap-1.5 pl-2 pr-1 h-6 rounded border border-hairline bg-surface text-slate-700 text-2xs">
|
||||
{userLabel(m.userId)}
|
||||
{m.role === 'owner' && <span className="text-[9px] text-blue-600">owner</span>}
|
||||
<button type="button" onClick={() => onRemoveMember(m.userId)} title="削除" className="text-slate-400 hover:text-red-500 leading-none px-0.5">×</button>
|
||||
<button type="button" onClick={() => onRemoveMember(m.userId)} title={t('orgs.removeMemberTitle')} className="text-slate-400 hover:text-red-500 leading-none px-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={pick} onChange={e => setPick(e.target.value)} className="flex-1 h-8 px-2 text-xs border border-hairline rounded-md bg-canvas">
|
||||
<option value="">メンバーを追加...</option>
|
||||
<option value="">{t('orgs.addMemberPlaceholder')}</option>
|
||||
{addable.map(u => <option key={u.id} value={u.id}>{u.name || u.email}</option>)}
|
||||
</select>
|
||||
<button
|
||||
@@ -177,7 +179,7 @@ function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRem
|
||||
onClick={() => { if (pick) { onAddMember(pick); setPick(''); } }}
|
||||
className="px-3 h-8 rounded-md text-xs font-medium border border-accent/60 text-accent hover:bg-accent-soft disabled:opacity-40 whitespace-nowrap"
|
||||
>
|
||||
追加
|
||||
{t('orgs.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -11,13 +12,14 @@ import type { SectionFormProps } from './types';
|
||||
* `storage.*`.
|
||||
*/
|
||||
export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
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> に集約されています。
|
||||
{t('pathsStorage.intro')}
|
||||
</HelpText>
|
||||
|
||||
<div>
|
||||
@@ -26,10 +28,10 @@ export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionF
|
||||
value={storage.worktreeDir ?? ''}
|
||||
onChange={v => onChange('storage.worktreeDir', v || undefined)}
|
||||
disabled={!!overriddenByEnv['storage.worktreeDir'] || !!overriddenByEnv['worktreeDir']}
|
||||
disabledReason="WORKTREE_DIR 環境変数で上書き中"
|
||||
disabledReason={t('pathsStorage.worktreeOverride')}
|
||||
/>
|
||||
{(overriddenByEnv['storage.worktreeDir'] || overriddenByEnv['worktreeDir']) && <EnvOverrideWarning />}
|
||||
<HelpText>ジョブ実行時の作業ディレクトリのベースパス</HelpText>
|
||||
<HelpText>{t('pathsStorage.worktreeHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -39,7 +41,7 @@ export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionF
|
||||
onChange={v => onChange('storage.customPiecesDir', v || undefined)}
|
||||
placeholder="/path/to/your/custom-pieces"
|
||||
/>
|
||||
<HelpText>リポジトリ内の pieces/ とは別に、追加の Piece を配置するディレクトリ。省略時は pieces/ のみ使用</HelpText>
|
||||
<HelpText>{t('pathsStorage.customPiecesHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -49,32 +51,30 @@ export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionF
|
||||
onChange={v => onChange('storage.userFolderRoot', v || undefined)}
|
||||
placeholder="./data/users"
|
||||
/>
|
||||
<HelpText>ユーザーごとの設定・スクリプト・メモリ等を保存するルートディレクトリ</HelpText>
|
||||
<HelpText>{t('pathsStorage.userFolderHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Task Upload 最大サイズ (MB)</FieldLabel>
|
||||
<FieldLabel>{t('pathsStorage.taskUploadLabel')}</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。
|
||||
{t('pathsStorage.taskUploadHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Trash Retention (日)</FieldLabel>
|
||||
<FieldLabel>{t('pathsStorage.trashLabel')}</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 日。
|
||||
{t('pathsStorage.trashHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { stringify, parse } from 'yaml';
|
||||
import { usePiece } from '../../hooks/usePieces';
|
||||
@@ -20,6 +21,7 @@ export interface PieceEditorProps {
|
||||
}
|
||||
|
||||
export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const { data: fetchResult, isLoading, error } = usePiece(name, source);
|
||||
// Use the server-resolved source as the authoritative value; fall back to the
|
||||
// prop only while the fetch hasn't completed yet (avoids flicker on known paths).
|
||||
@@ -32,6 +34,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [toastIsError, setToastIsError] = useState(false);
|
||||
|
||||
// YAML editing mode
|
||||
const [editMode, setEditMode] = useState<'visual' | 'yaml'>('visual');
|
||||
@@ -47,9 +50,10 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
}
|
||||
}, [piece]); // piece is derived from fetchResult above
|
||||
|
||||
const showToast = (msg: string, duration = 2000) => {
|
||||
const showToast = (msg: string, opts: { isError?: boolean; duration?: number } = {}) => {
|
||||
setToastIsError(opts.isError ?? false);
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), duration);
|
||||
setTimeout(() => setToast(null), opts.duration ?? 2000);
|
||||
};
|
||||
|
||||
const handleMetaChange = useCallback((field: string, value: any) => {
|
||||
@@ -122,7 +126,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
try {
|
||||
const parsed = parse(yamlText);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
setYamlError('YAML のパースに失敗しました');
|
||||
setYamlError(t('pieceEditor.yamlParseFailed'));
|
||||
return;
|
||||
}
|
||||
setDraft(parsed);
|
||||
@@ -157,11 +161,11 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
try {
|
||||
saveData = parse(yamlText);
|
||||
if (!saveData || typeof saveData !== 'object') {
|
||||
showToast('エラー: YAML のパースに失敗しました', 3000);
|
||||
showToast(t('pieceEditor.toastSaveYamlParse'), { isError: true, duration: 3000 });
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: YAML パースエラー — ${e.message}`, 3000);
|
||||
showToast(t('pieceEditor.toastSaveYamlError', { msg: e.message }), { isError: true, duration: 3000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -178,27 +182,27 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
if (editMode === 'yaml') {
|
||||
setDraft(saveData);
|
||||
}
|
||||
showToast('保存しました');
|
||||
showToast(t('pieceEditor.toastSaved'));
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: ${e.message}`, 3000);
|
||||
showToast(t('pieceEditor.toastError', { msg: e.message }), { isError: true, duration: 3000 });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
|
||||
if (!confirm(t('pieceEditor.confirmDelete', { name }))) return;
|
||||
try {
|
||||
await deletePiece(name, effectiveSource);
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: ${e.message}`, 3000);
|
||||
showToast(t('pieceEditor.toastError', { msg: e.message }), { isError: true, duration: 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 (error) return <div className="text-sm text-red-500">{t('pieceEditor.loadError')}</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
// Non-admins cannot edit built-in or global-custom pieces — read-only view.
|
||||
@@ -231,7 +235,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
)}
|
||||
{readonly && (
|
||||
<span className="px-2 py-1 text-xs text-slate-400 bg-slate-100 rounded border border-slate-200">
|
||||
読み取り専用
|
||||
{t('pieceEditor.readonly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -302,7 +306,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
style={{ minHeight: '500px', tabSize: 2 }}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
YAML を直接編集できます。Visual モードに切り替えると自動でパースされます。
|
||||
{t('pieceEditor.yamlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -311,7 +315,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
|
||||
{!readonly && (
|
||||
<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'}`}>
|
||||
<span className={`text-xs mr-auto ${toastIsError ? 'text-red-500' : 'text-green-600'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
export interface PieceMetaFormProps {
|
||||
@@ -8,6 +9,7 @@ export interface PieceMetaFormProps {
|
||||
}
|
||||
|
||||
export function PieceMetaForm({ piece, onChange, movementNames, disabled = false }: PieceMetaFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
|
||||
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||
|
||||
@@ -22,7 +24,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
|
||||
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>
|
||||
<HelpText>{t('pieceMeta.nameHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
@@ -48,7 +50,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
|
||||
disabled={disabled}
|
||||
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 ${disabledClass}`}
|
||||
/>
|
||||
<HelpText>1 ジョブで実行できる movement の最大回数。ループ防止のため</HelpText>
|
||||
<HelpText>{t('pieceMeta.maxMovementsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* initial_movement */}
|
||||
@@ -65,7 +67,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<HelpText>ジョブ開始時に最初に実行される movement です</HelpText>
|
||||
<HelpText>{t('pieceMeta.initialMovementHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* triggers.keywords */}
|
||||
@@ -85,7 +87,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
|
||||
disabled={disabled}
|
||||
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 ${disabledClass}`}
|
||||
/>
|
||||
<HelpText>タスク本文にこれらのキーワードが含まれると、この piece が自動選択されます</HelpText>
|
||||
<HelpText>{t('pieceMeta.keywordsHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchMyOrgs, Visibility } from '../../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, type SupportedLanguage } from '../../i18n';
|
||||
|
||||
const LANGUAGE_LABELS: Record<SupportedLanguage, string> = { en: 'English', ja: '日本語' };
|
||||
|
||||
export function PreferencesForm({ user }: { user: { defaultVisibility: Visibility; defaultVisibilityOrgId: string | null } }) {
|
||||
const { t, i18n } = useTranslation('settings');
|
||||
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 changeLanguage = (lng: string) => {
|
||||
void i18n.changeLanguage(lng);
|
||||
try { localStorage.setItem(LANGUAGE_STORAGE_KEY, lng); } catch { /* storage may be unavailable */ }
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch('/api/users/me/preferences', {
|
||||
@@ -25,15 +35,24 @@ export function PreferencesForm({ user }: { user: { defaultVisibility: Visibilit
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">新規タスクのデフォルト公開範囲</h3>
|
||||
<h3 className="text-sm font-bold text-slate-900">{t('preferences.language.title')}</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>
|
||||
{SUPPORTED_LANGUAGES.map(lng => (
|
||||
<label key={lng}>
|
||||
<input type="radio" name="ui-language" checked={i18n.resolvedLanguage === lng} onChange={() => changeLanguage(lng)} /> {LANGUAGE_LABELS[lng]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>
|
||||
🔒 非公開: 自分のみ閲覧可能 / 🏢 組織: 同じ Gitea org のメンバーが閲覧可能 / 🌐 公開: ログイン中の全ユーザーが閲覧可能
|
||||
</HelpText>
|
||||
<HelpText>{t('preferences.language.help')}</HelpText>
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">{t('preferences.visibility.title')}</h3>
|
||||
<div className="mt-2 flex gap-3 text-[13px]">
|
||||
<label><input type="radio" checked={vis === 'private'} onChange={() => setVis('private')} /> 🔒 {t('preferences.visibility.private')}</label>
|
||||
<label><input type="radio" checked={vis === 'org'} onChange={() => setVis('org')} disabled={orgs.length === 0} /> 🏢 {t('preferences.visibility.org')}</label>
|
||||
<label><input type="radio" checked={vis === 'public'} onChange={() => setVis('public')} /> 🌐 {t('preferences.visibility.public')}</label>
|
||||
</div>
|
||||
<HelpText>{t('preferences.visibility.help')}</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>)}
|
||||
@@ -41,19 +60,19 @@ export function PreferencesForm({ user }: { user: { defaultVisibility: Visibilit
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">所属している Gitea 組織</h3>
|
||||
<h3 className="text-sm font-bold text-slate-900">{t('preferences.orgs.title')}</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>}
|
||||
{orgs.length === 0 && <li className="text-slate-400">{t('preferences.orgs.none')}</li>}
|
||||
</ul>
|
||||
<p className="mt-2 text-2xs text-slate-500">最新状態に更新するには、一度ログアウトして再ログインしてください。</p>
|
||||
<p className="mt-2 text-2xs text-slate-500">{t('preferences.orgs.refreshHint')}</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 ? '保存中…' : '設定を保存'}
|
||||
{save.isPending ? t('preferences.saving') : t('preferences.save')}
|
||||
</button>
|
||||
{save.isError && <div className="text-red-600 text-xs">{String(save.error)}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -9,14 +10,14 @@ import type { SectionFormProps } from './types';
|
||||
* subscription, not whether the server feature is enabled at all).
|
||||
*/
|
||||
export function PushNotificationsForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const push = config.notifications?.push ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Web Push (Server)</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('pushNotifications.title')}</h2>
|
||||
<p className="text-[13px] text-slate-500">
|
||||
ブラウザ通知 V2(Web Push)のサーバ設定。HTTPS ホスティング必須(iOS は PWA インストールも)。
|
||||
各ユーザーの購読操作は 🔔 Notifications タブで行う。
|
||||
{t('pushNotifications.intro')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
@@ -27,51 +28,51 @@ export function PushNotificationsForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => onChange('notifications.push.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Web Push を有効化
|
||||
{t('pushNotifications.enableLabel')}
|
||||
</label>
|
||||
<HelpText>マスタースイッチ。デフォルト: 無効(オペレーターが明示的に opt-in)</HelpText>
|
||||
<HelpText>{t('pushNotifications.enableHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>VAPID Subject</FieldLabel>
|
||||
<FieldLabel>{t('pushNotifications.subjectLabel')}</FieldLabel>
|
||||
<FieldInput value={push.vapidSubject ?? ''} placeholder="https://example.com/"
|
||||
onChange={v => onChange('notifications.push.vapidSubject', v || undefined)} />
|
||||
<HelpText>RFC 8292 の VAPID subject。汎用 mailto: より運用 URL を推奨</HelpText>
|
||||
<HelpText>{t('pushNotifications.subjectHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>VAPID Current Key Path</FieldLabel>
|
||||
<FieldLabel>{t('pushNotifications.currentPathLabel')}</FieldLabel>
|
||||
<FieldInput value={push.vapidCurrentPath ?? ''} placeholder="./data/secrets/vapid.json"
|
||||
onChange={v => onChange('notifications.push.vapidCurrentPath', v || undefined)} />
|
||||
<HelpText>現行 VAPID 鍵ペアファイルのパス(未生成なら mode 0600 で自動生成)</HelpText>
|
||||
<HelpText>{t('pushNotifications.currentPathHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>VAPID History Dir</FieldLabel>
|
||||
<FieldLabel>{t('pushNotifications.historyDirLabel')}</FieldLabel>
|
||||
<FieldInput value={push.vapidHistoryDir ?? ''} placeholder="./data/secrets/vapid-history"
|
||||
onChange={v => onChange('notifications.push.vapidHistoryDir', v || undefined)} />
|
||||
<HelpText>失効した VAPID 鍵を退避するディレクトリ</HelpText>
|
||||
<HelpText>{t('pushNotifications.historyDirHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Payload Max Bytes</FieldLabel>
|
||||
<FieldLabel>{t('pushNotifications.payloadMaxLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={push.payloadMaxBytes ?? ''}
|
||||
onChange={v => onChange('notifications.push.payloadMaxBytes', v ? Number(v) : undefined)} />
|
||||
<HelpText>暗号化前のプッシュペイロード最大バイト数(上限 4096)。デフォルト: 3072</HelpText>
|
||||
<HelpText>{t('pushNotifications.payloadMaxHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Queue Concurrency</FieldLabel>
|
||||
<FieldLabel>{t('pushNotifications.queueConcurrencyLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={push.queueConcurrency ?? ''}
|
||||
onChange={v => onChange('notifications.push.queueConcurrency', v ? Number(v) : undefined)} />
|
||||
<HelpText>キューからの同時送信数。デフォルト: 8</HelpText>
|
||||
<HelpText>{t('pushNotifications.queueConcurrencyHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Per-Send Timeout (ms)</FieldLabel>
|
||||
<FieldLabel>{t('pushNotifications.perSendTimeoutLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={push.perSendTimeoutMs ?? ''}
|
||||
onChange={v => onChange('notifications.push.perSendTimeoutMs', v ? Number(v) : undefined)} />
|
||||
<HelpText>1 送信あたりのタイムアウト(ミリ秒)。デフォルト: 10000</HelpText>
|
||||
<HelpText>{t('pushNotifications.perSendTimeoutHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -14,6 +15,7 @@ import type { SectionFormProps } from './types';
|
||||
* jobs are silently skipped.
|
||||
*/
|
||||
export function ReflectionForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
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
|
||||
@@ -30,10 +32,7 @@ export function ReflectionForm({ config, onChange }: SectionFormProps) {
|
||||
<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 可能です。
|
||||
{t('reflection.intro')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
@@ -44,29 +43,16 @@ export function ReflectionForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => onChange('reflection.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-semibold">Reflection を有効化(自動適用)</span>
|
||||
<span className="font-semibold">{t('reflection.enableLabel')}</span>
|
||||
</label>
|
||||
<HelpText>
|
||||
ON にすると、エージェントジョブが終わるたびに reflection ジョブが裏で走り、memory
|
||||
を自動で書き換えます。デフォルト: 無効。
|
||||
</HelpText>
|
||||
<HelpText>{t('reflection.enableHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{enabled && !hasReflectionWorker && (
|
||||
<div className="rounded-md border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300">
|
||||
<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-canvas 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 className="font-semibold mb-1">{t('reflection.workerWarn.title')}</div>
|
||||
<div>{t('reflection.workerWarn.body')}</div>
|
||||
<pre className="mt-2 text-2xs font-mono bg-canvas border border-amber-200 rounded p-2 overflow-auto">{t('reflection.workerWarn.snippet')}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,13 +64,9 @@ max_concurrency: 1`}</pre>
|
||||
onChange={e => onChange('reflection.workerRequired', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
専用 reflection worker を必須にする
|
||||
{t('reflection.workerRequiredLabel')}
|
||||
</label>
|
||||
<HelpText>
|
||||
ON: <code className="font-mono">roles: [reflection]</code> を持つ worker が無い場合、
|
||||
reflection ジョブを enqueue せずスキップします (デフォルト)。OFF にすると enabled
|
||||
のみで他 worker に拾われる可能性あり。
|
||||
</HelpText>
|
||||
<HelpText>{t('reflection.workerRequiredHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Caps</h3>
|
||||
@@ -96,7 +78,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.maxMemoryChangesPerJob ?? 3}
|
||||
onChange={v => onChange('reflection.maxMemoryChangesPerJob', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ジョブの reflection で書き込める memory entry の上限。デフォルト: 3</HelpText>
|
||||
<HelpText>{t('reflection.maxMemHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -106,7 +88,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.maxEntryBodyBytes ?? 8192}
|
||||
onChange={v => onChange('reflection.maxEntryBodyBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>memory entry body の最大バイト数。これを超えると semantic validator が reject。デフォルト: 8192</HelpText>
|
||||
<HelpText>{t('reflection.maxBodyHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -116,7 +98,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.pieceEditCooldownHours ?? 24}
|
||||
onChange={v => onChange('reflection.pieceEditCooldownHours', Number(v))}
|
||||
/>
|
||||
<HelpText>同じ piece への連続編集を抑制する cooldown。デフォルト: 24 (24h 以内に 2 回編集されたら 3 回目以降はスキップ)</HelpText>
|
||||
<HelpText>{t('reflection.cooldownHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -126,7 +108,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.activityLogMaxBytes ?? 4096}
|
||||
onChange={v => onChange('reflection.activityLogMaxBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>reflection LLM に渡す activity log の圧縮上限。デフォルト: 4096</HelpText>
|
||||
<HelpText>{t('reflection.activityLogHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Budget</h3>
|
||||
@@ -138,7 +120,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.perUserDailyBudgetTokens ?? 200000}
|
||||
onChange={v => onChange('reflection.perUserDailyBudgetTokens', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ユーザーが 1 日に reflection で消費できる token 合計。超えた以降の reflection は enqueue されません。デフォルト: 200000</HelpText>
|
||||
<HelpText>{t('reflection.dailyBudgetHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Snapshot & Retention</h3>
|
||||
@@ -150,7 +132,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.snapshotRetentionDays ?? 90}
|
||||
onChange={v => onChange('reflection.snapshotRetentionDays', Number(v))}
|
||||
/>
|
||||
<HelpText>reflection-history snapshot の保持日数。デフォルト: 90</HelpText>
|
||||
<HelpText>{t('reflection.snapRetentionHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -160,7 +142,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.snapshotMaxBytesPerUser ?? 100 * 1024 * 1024}
|
||||
onChange={v => onChange('reflection.snapshotMaxBytesPerUser', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ユーザーあたりの snapshot ディレクトリ合計サイズ上限 (bytes)。超えると古い順に削除。デフォルト: 100 MiB</HelpText>
|
||||
<HelpText>{t('reflection.snapMaxUserHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -170,7 +152,7 @@ max_concurrency: 1`}</pre>
|
||||
value={reflection.snapshotMaxBytesPerEntry ?? 1 * 1024 * 1024}
|
||||
onChange={v => onChange('reflection.snapshotMaxBytesPerEntry', Number(v))}
|
||||
/>
|
||||
<HelpText>1 snapshot エントリの最大サイズ (bytes)。デフォルト: 1 MiB</HelpText>
|
||||
<HelpText>{t('reflection.snapMaxEntryHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -181,12 +163,9 @@ max_concurrency: 1`}</pre>
|
||||
onChange={e => onChange('reflection.storeLlmRaw', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
スナップショットに LLM の生レスポンスを保存する
|
||||
{t('reflection.storeLlmRawLabel')}
|
||||
</label>
|
||||
<HelpText>
|
||||
ON にすると <code className="font-mono">llm-raw.json</code> を snapshot に含めます。
|
||||
デバッグ用途、デフォルトは OFF (ディスク節約)。
|
||||
</HelpText>
|
||||
<HelpText>{t('reflection.storeLlmRawHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Monitoring</h3>
|
||||
@@ -198,11 +177,7 @@ max_concurrency: 1`}</pre>
|
||||
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>
|
||||
<HelpText>{t('reflection.abstainFloorHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
const SPECIAL_TARGETS = ['COMPLETE', 'ASK', 'ABORT', 'WAIT_SUBTASKS'];
|
||||
@@ -10,6 +11,7 @@ export interface RulesTableProps {
|
||||
}
|
||||
|
||||
export function RulesTable({ rules, movementNames, onChange, disabled = false }: RulesTableProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
|
||||
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||
|
||||
@@ -48,7 +50,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
|
||||
onChange={(e) => updateRule(i, 'condition', e.target.value)}
|
||||
disabled={disabled}
|
||||
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 ${disabledClass}`}
|
||||
placeholder="条件..."
|
||||
placeholder={t('rules.conditionPlaceholder')}
|
||||
/>
|
||||
</td>
|
||||
<td className="pr-2 pb-1">
|
||||
@@ -88,7 +90,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
|
||||
+ Add Rule
|
||||
</button>
|
||||
)}
|
||||
<HelpText>LLM が transition ツールで遷移先を選ぶ際の条件です</HelpText>
|
||||
<HelpText>{t('rules.help')}</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
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>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('safety.title')}</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>
|
||||
<HelpText>{t('safety.maxIterHelp')}</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>
|
||||
<HelpText>{t('safety.maxRevisitsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Tool Loop Repeats</FieldLabel>
|
||||
<FieldInput type="number" value={safety.maxToolLoopRepeats ?? 5}
|
||||
onChange={v => onChange('safety.maxToolLoopRepeats', Number(v))} />
|
||||
<HelpText>同一 movement 内で全く同じツール呼び出し(ツール名+引数)を連続で繰り返した回数がこの値に達したら、ループとみなして強制中断する(2以上、デフォルト: 5)。手前で1回エージェントに警告を注入する</HelpText>
|
||||
<HelpText>{t('safety.maxToolLoopHelp')}</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>
|
||||
<HelpText>{t('safety.promptGuardHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Bash サンドボックス</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('safety.bashSandboxTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Bash Sandbox Mode</FieldLabel>
|
||||
@@ -47,11 +49,11 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => onChange('safety.bashSandbox', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
|
||||
>
|
||||
<option value="auto">auto(bwrap があれば sandboxed、無ければ hardened-whitelist)</option>
|
||||
<option value="always">always(sandboxed を強制・bwrap 不在なら起動時 fail)</option>
|
||||
<option value="off">off(素 exec・後方互換/非推奨)</option>
|
||||
<option value="auto">{t('safety.bashSandboxAuto')}</option>
|
||||
<option value="always">{t('safety.bashSandboxAlways')}</option>
|
||||
<option value="off">{t('safety.bashSandboxOff')}</option>
|
||||
</select>
|
||||
<HelpText>Bash ツールのサンドボックス機構。デフォルト: auto</HelpText>
|
||||
<HelpText>{t('safety.bashSandboxHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -62,9 +64,9 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => onChange('safety.bashUnrestricted', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
コマンドのホワイトリストを解除(無制限 Bash)
|
||||
{t('safety.bashUnrestricted')}
|
||||
</label>
|
||||
<HelpText>bwrap サンドボックス下で任意コマンドを許可する。ワークスペース(rw)とシステムディレクトリ(ro)のみ bind-mount。bwrap の user-namespace 対応が必要。デフォルト: 無効(セキュリティ上、変更は慎重に)</HelpText>
|
||||
<HelpText>{t('safety.bashUnrestrictedHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -75,19 +77,15 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => onChange('safety.bashAllowNetwork', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
サンドボックスでネットワークを許可(bash / python / npm がネット接続可)
|
||||
{t('safety.bashAllowNetwork')}
|
||||
</label>
|
||||
<HelpText>
|
||||
<span className="text-red-700 dark:text-red-300 font-medium">⚠ セキュリティ警告:</span>{' '}
|
||||
通常はサンドボックス内の bash・python・npm はネット遮断(<code>--unshare-net</code>)されます。
|
||||
有効にすると pip / npm install / curl 等が使えるようになりますが、
|
||||
<strong>サンドボックスの隔離が弱まり、任意のデータ送信(情報漏えい)や内部ネットワーク・メタデータエンドポイントへの到達(SSRF)が可能になります</strong>。
|
||||
信頼できる環境でのみ有効化してください。デフォルト: 無効。
|
||||
(Bash Sandbox Mode が <code>off</code> や bwrap 不在時は元々ネット遮断が無いため、この設定は sandboxed 時のみ効きます)
|
||||
<span className="text-red-700 dark:text-red-300 font-medium">{t('safety.bashAllowNetworkWarnLabel')}</span>
|
||||
{t('safety.bashAllowNetworkHelp')}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">History Summarization</h3>
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('safety.historyTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
@@ -97,23 +95,23 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={e => onChange('safety.historySummarization.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
履歴の自動要約を有効化
|
||||
{t('safety.historyEnable')}
|
||||
</label>
|
||||
<HelpText>古い会話履歴を自動で要約して context を節約。デフォルト: 有効</HelpText>
|
||||
<HelpText>{t('safety.historyEnableHelp')}</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>
|
||||
<HelpText>{t('safety.tailTurnsHelp')}</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>
|
||||
<HelpText>{t('safety.preserveRecentHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function SearchFilterForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const sf = config.searchFilter ?? {};
|
||||
const autoBlock = sf.autoBlock ?? {};
|
||||
|
||||
@@ -16,23 +18,23 @@ export function SearchFilterForm({ config, onChange }: SectionFormProps) {
|
||||
<h2 className="text-base font-semibold text-slate-800">Search Filter</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Blocked Patterns (ブロックパターン)</FieldLabel>
|
||||
<FieldLabel>{t('searchFilter.blockedLabel')}</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={sf.blockedPatterns ?? []}
|
||||
onChange={v => onChange('searchFilter.blockedPatterns', v)}
|
||||
placeholder="regex pattern"
|
||||
/>
|
||||
<HelpText>WebSearch クエリからフィルタするパターン(正規表現)。</HelpText>
|
||||
<HelpText>{t('searchFilter.blockedHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Auto Block (自動ブロック)</FieldLabel>
|
||||
<FieldLabel>{t('searchFilter.autoBlockLabel')}</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{([
|
||||
['privateIp', 'プライベートIP', autoBlock.privateIp],
|
||||
['internalDomain', '内部ドメイン', autoBlock.internalDomain],
|
||||
['email', 'メールアドレス', autoBlock.email],
|
||||
['phone', '電話番号', autoBlock.phone],
|
||||
['privateIp', t('searchFilter.autoBlockPrivateIp'), autoBlock.privateIp],
|
||||
['internalDomain', t('searchFilter.autoBlockInternalDomain'), autoBlock.internalDomain],
|
||||
['email', t('searchFilter.autoBlockEmail'), autoBlock.email],
|
||||
['phone', t('searchFilter.autoBlockPhone'), autoBlock.phone],
|
||||
] as const).map(([key, label, checked]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
@@ -45,7 +47,7 @@ export function SearchFilterForm({ config, onChange }: SectionFormProps) {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>検索クエリに含まれる機密情報を自動でブロック。</HelpText>
|
||||
<HelpText>{t('searchFilter.autoBlockHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
activeSection?: string;
|
||||
onSelectSection: (section: string) => void;
|
||||
@@ -24,7 +26,7 @@ const CONFIG_GROUPS = [
|
||||
sections: [
|
||||
{ id: 'preferences', label: 'Preferences' },
|
||||
{ id: 'notifications', label: '🔔 Notifications' },
|
||||
{ id: 'memory-learning', label: '🧠 Reflection 履歴' },
|
||||
{ id: 'memory-learning', label: '🧠 Reflection history', labelKey: 'memoryLearning.navLabel' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -121,6 +123,7 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS
|
||||
.flatMap(g => g.sections.map(s => s.id));
|
||||
|
||||
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly);
|
||||
|
||||
return (
|
||||
@@ -137,7 +140,7 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: Set
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}>
|
||||
{s.label}
|
||||
{'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { SshAuditRow } from '../../lib/ssh-types';
|
||||
|
||||
@@ -44,6 +45,7 @@ const ACTION_HINTS = [
|
||||
];
|
||||
|
||||
export function SshAuditLog() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
action: '',
|
||||
ownerId: '',
|
||||
@@ -63,7 +65,7 @@ export function SshAuditLog() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">監査ログ</h3>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.audit.title')}</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-2xs">
|
||||
<label className="block">
|
||||
@@ -123,9 +125,9 @@ export function SshAuditLog() {
|
||||
disabled={isFetching}
|
||||
className="px-2 h-6 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{isFetching ? '更新中…' : '再読み込み'}
|
||||
{isFetching ? t('ssh.audit.refreshing') : t('ssh.audit.reload')}
|
||||
</button>
|
||||
<span className="text-2xs text-slate-500">{data?.length ?? 0} 件表示 (limit {filters.limit})</span>
|
||||
<span className="text-2xs text-slate-500">{t('ssh.audit.countDisplay', { count: data?.length ?? 0, limit: filters.limit })}</span>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-xs text-slate-400">Loading…</div>}
|
||||
@@ -162,7 +164,7 @@ export function SshAuditLog() {
|
||||
</tr>
|
||||
))}
|
||||
{(data ?? []).length === 0 && !isLoading && (
|
||||
<tr><td colSpan={6} className="px-2 py-4 text-center text-slate-400">該当する監査ログがありません</td></tr>
|
||||
<tr><td colSpan={6} className="px-2 py-4 text-center text-slate-400">{t('ssh.audit.empty')}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -11,6 +12,7 @@ import type { SectionFormProps } from './types';
|
||||
* sibling subtabs of `SshForm`.
|
||||
*/
|
||||
export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenByEnv }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const ssh = config.ssh ?? {};
|
||||
const console_ = ssh.console ?? {};
|
||||
|
||||
@@ -24,13 +26,9 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
onChange={e => onChange('ssh.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
SSH 機能を有効化する
|
||||
{t('ssh.config.enableLabel')}
|
||||
</label>
|
||||
<HelpText>
|
||||
OFF の間は SshExec / SshUpload / SshDownload / SshConsole* 全ツールが利用不可になり、
|
||||
関連 API も router に登録されない。<code className="font-mono">MCP_ENCRYPTION_KEY</code> 環境変数も
|
||||
別途必須 (鍵が無い場合は ON にしても subsystem は disabled で起動)。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.enableHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -41,13 +39,9 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
onChange={e => onChange('ssh.allowPrivateAddresses', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
プライベート / loopback アドレスへの接続を許可する
|
||||
{t('ssh.config.allowPrivateLabel')}
|
||||
</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>
|
||||
<HelpText>{t('ssh.config.allowPrivateHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -58,12 +52,9 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
onChange={e => onChange('ssh.adminBypassesGrants', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Admin は grant 無しでも接続を利用できる
|
||||
{t('ssh.config.adminBypassLabel')}
|
||||
</label>
|
||||
<HelpText>
|
||||
ON: admin role の user は per-connection grant 無しでも全接続にアクセス可
|
||||
(監査ログには記録される)。OFF: admin もユーザーと同じく明示的 grant 必須。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.adminBypassHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
|
||||
@@ -71,16 +62,13 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Call timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('ssh.config.callTimeout')}</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>
|
||||
<HelpText>{t('ssh.config.callTimeoutHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -90,10 +78,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
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>
|
||||
<HelpText>{t('ssh.config.maxOutputHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -114,32 +99,26 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpText>SshUpload / SshDownload のファイルサイズ上限。超過は転送前に reject。</HelpText>
|
||||
<HelpText>{t('ssh.config.transferSizeHelp')}</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Audit retention (日)</FieldLabel>
|
||||
<FieldLabel>{t('ssh.config.auditRetention')}</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>
|
||||
<HelpText>{t('ssh.config.auditRetentionHelp')}</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>
|
||||
<HelpText>{t('ssh.config.abuseHelp')}</HelpText>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<FieldLabel>Window (分)</FieldLabel>
|
||||
<FieldLabel>{t('ssh.config.abuseWindow')}</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.abuseWindowMinutes ?? 10}
|
||||
@@ -155,7 +134,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Lock duration (分)</FieldLabel>
|
||||
<FieldLabel>{t('ssh.config.abuseLock')}</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={ssh.abuseLockMinutes ?? 30}
|
||||
@@ -165,7 +144,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
|
||||
Interactive Console (SSH タブ / SshConsole* tools)
|
||||
{t('ssh.config.consoleSectionTitle')}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
@@ -176,39 +155,29 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
onChange={e => onChange('ssh.console.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Console 機能を有効化する
|
||||
{t('ssh.config.consoleEnableLabel')}
|
||||
</label>
|
||||
<HelpText>
|
||||
OFF だと SshConsole* tools と <code className="font-mono">SSH</code> タブが無効。
|
||||
上の "SSH 機能を有効化する" と <code className="font-mono">MCP_ENCRYPTION_KEY</code> も
|
||||
別途必須。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.consoleEnableHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel>Idle timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('ssh.config.idleTimeout')}</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>
|
||||
<HelpText>{t('ssh.config.idleTimeoutHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Max session duration (秒)</FieldLabel>
|
||||
<FieldLabel>{t('ssh.config.maxSessionDuration')}</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={console_.maxSessionDurationSeconds ?? 14400}
|
||||
onChange={v => onChange('ssh.console.maxSessionDurationSeconds', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
1 セッションの絶対上限。Idle じゃなくてもこの時間を超えると強制 close。
|
||||
デフォルト 14400 (4 時間)。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.maxDurationHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,10 +188,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
value={console_.scrollbackBytes ?? 524288}
|
||||
onChange={v => onChange('ssh.console.scrollbackBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
サーバー側で保持する PTY 出力履歴のリングバッファ容量。ブラウザ再接続時に
|
||||
replay される量。デフォルト 524288 (512 KiB)。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.scrollbackHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -232,10 +198,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
value={console_.maxSessionsPerConnection ?? 3}
|
||||
onChange={v => onChange('ssh.console.maxSessionsPerConnection', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
同じ接続を使う並列セッション数の上限。超えた場合は最も古いセッションが
|
||||
<code className="font-mono">session_cap_evict</code> 理由で close される。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.maxSessionsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -245,10 +208,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
value={console_.maxInputBytesPerSend ?? 16384}
|
||||
onChange={v => onChange('ssh.console.maxInputBytesPerSend', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
1 回の <code className="font-mono">SshConsoleSend</code> で送れる最大バイト数。
|
||||
デフォルト 16384 (16 KiB)。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.maxInputHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -258,10 +218,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
value={console_.autoInjectScreenLines ?? 24}
|
||||
onChange={v => onChange('ssh.console.autoInjectScreenLines', Number(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
各 LLM iteration の system prompt 末尾に挿入する screen の末尾行数。
|
||||
多いほど AI の状況認識が良くなるが context を消費する。デフォルト 24 行。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.autoInjectHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -282,9 +239,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpText>
|
||||
PTY サイズの初期値。クライアントが resize イベントを送れば上書きされる。
|
||||
</HelpText>
|
||||
<HelpText>{t('ssh.config.ptySizeHelp')}</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SshGlobalConnectionsForm } from './SshGlobalConnectionsForm';
|
||||
import { SshGrantsForm } from './SshGrantsForm';
|
||||
import { SshMasterKeyRotationForm } from './SshMasterKeyRotationForm';
|
||||
@@ -33,16 +34,15 @@ interface Props extends SectionFormProps {
|
||||
* very different shapes (form / lists / per-row CRUD / mode / table).
|
||||
*/
|
||||
export function SshForm({ config, onChange, overriddenByEnv, showToast }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
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>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('ssh.header.title')}</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> から管理します。
|
||||
{t('ssh.header.desc')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SshConnection, TestResponse } from '../../lib/ssh-types';
|
||||
import { SshConnectionForm } from '../userfolder/SshConnectionForm';
|
||||
@@ -62,6 +63,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['ssh', 'admin', 'connections'],
|
||||
@@ -91,7 +93,7 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
onSuccess: (resp) => {
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
showToast?.('グローバル接続を作成しました', 'success');
|
||||
showToast?.(t('ssh.connections.toast.created'), 'success');
|
||||
if (resp.publicKey) {
|
||||
setPubKeyDialog({
|
||||
publicKey: resp.publicKey,
|
||||
@@ -112,11 +114,11 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
if (publicKey) {
|
||||
setPubKeyDialog({ publicKey, label, freshlyGenerated: false });
|
||||
} else {
|
||||
showToast?.('公開鍵の取得に失敗しました', 'error');
|
||||
showToast?.(t('ssh.connections.toast.pubKeyFailed'), 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : '公開鍵取得失敗', 'error');
|
||||
showToast?.(e instanceof Error ? e.message : t('ssh.connections.toast.pubKeyFailedShort'), 'error');
|
||||
},
|
||||
});
|
||||
const patchMutation = useMutation({
|
||||
@@ -125,29 +127,29 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setEditingId(null);
|
||||
showToast?.('グローバル接続を更新しました', 'success');
|
||||
showToast?.(t('ssh.connections.toast.updated'), 'success');
|
||||
},
|
||||
});
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
patchJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/disable`, { reason }),
|
||||
onSuccess: () => { invalidate(); showToast?.('接続を無効化しました', 'success'); },
|
||||
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.disabled'), 'success'); },
|
||||
});
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
patchJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/enable`, { reason }),
|
||||
onSuccess: () => { invalidate(); showToast?.('接続を有効化しました', 'success'); },
|
||||
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.enabled'), 'success'); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
deleteJson(`/api/ssh/admin/globals/${encodeURIComponent(id)}`, { reason }),
|
||||
onSuccess: () => { invalidate(); showToast?.('接続を削除しました', 'success'); },
|
||||
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.deleted'), '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'); },
|
||||
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.unlocked'), 'success'); },
|
||||
onError: (e) => { showToast?.(e instanceof Error ? e.message : t('ssh.connections.toast.unlockFailed'), 'error'); },
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<{ id: string; resp: TestResponse }> => {
|
||||
@@ -158,14 +160,14 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
onSuccess: ({ id, resp }) => {
|
||||
invalidate();
|
||||
if (resp.verdict === 'pass') {
|
||||
showToast?.(`ホストキーは一致しています (${resp.fingerprint.slice(0, 20)}…)`, 'success');
|
||||
showToast?.(t('ssh.connections.toast.hostKeyMatch', { fp: 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');
|
||||
showToast?.(t('ssh.connections.toast.algNotAllowed'), 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => { showToast?.(e instanceof Error ? e.message : 'テスト失敗', 'error'); },
|
||||
onError: (e) => { showToast?.(e instanceof Error ? e.message : t('ssh.connections.toast.testFailed'), 'error'); },
|
||||
});
|
||||
|
||||
async function handleVerify(connId: string, args: { fingerprint: string; token: string; reason?: string }) {
|
||||
@@ -178,15 +180,13 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
invalidate();
|
||||
showToast?.('ホストキーを検証しました', 'success');
|
||||
showToast?.(t('ssh.connections.toast.hostKeyVerified'), '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>{' '}
|
||||
を設定後にサーバーを再起動してください。
|
||||
{t('ssh.connections.disabledSubsystem')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -196,14 +196,14 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
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>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.connections.listTitle', { count: 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}
|
||||
>
|
||||
+ グローバル接続を追加
|
||||
{t('ssh.connections.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
|
||||
{creating && (
|
||||
<section className="border border-accent/40 rounded-md bg-canvas p-4">
|
||||
<h4 className="text-xs font-semibold text-slate-700 mb-2">新規グローバル接続</h4>
|
||||
<h4 className="text-xs font-semibold text-slate-700 mb-2">{t('ssh.connections.newConnTitle')}</h4>
|
||||
<SshConnectionForm
|
||||
existing={null}
|
||||
adminContext
|
||||
@@ -223,7 +223,7 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
)}
|
||||
|
||||
{globals.length === 0 && !creating && !isLoading && (
|
||||
<div className="text-xs text-slate-400 px-3 py-4">グローバル接続はまだありません。</div>
|
||||
<div className="text-xs text-slate-400 px-3 py-4">{t('ssh.connections.empty')}</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-hairline">
|
||||
@@ -258,33 +258,33 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
)}
|
||||
</div>
|
||||
{c.disabledByAdminReason && (
|
||||
<div className="text-2xs text-red-700 dark:text-red-300 mt-0.5">理由: {c.disabledByAdminReason}</div>
|
||||
<div className="text-2xs text-red-700 dark:text-red-300 mt-0.5">{t('ssh.common.reasonLabel', { reason: 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'}
|
||||
{testMutation.isPending && testMutation.variables === c.id ? t('ssh.connections.testing') : t('ssh.connections.test')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
||||
disabled={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
||||
title="authorized_keys に貼る公開鍵を表示"
|
||||
title={t('ssh.connections.pubKeyTitle')}
|
||||
className={btnCls}
|
||||
>
|
||||
{showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id ? '取得中…' : '公開鍵'}
|
||||
{showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id ? t('ssh.connections.fetching') : t('ssh.connections.pubKey')}
|
||||
</button>
|
||||
<button onClick={() => { setEditingId(c.id); setCreating(false); }} className={btnCls}>
|
||||
編集
|
||||
{t('ssh.connections.edit')}
|
||||
</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: 'enable', conn: c })} className={btnCls}>{t('ssh.connections.enable')}</button>
|
||||
) : (
|
||||
<button onClick={() => setReasonForOp({ kind: 'disable', conn: c })} className={btnCls}>無効化</button>
|
||||
<button onClick={() => setReasonForOp({ kind: 'disable', conn: c })} className={btnCls}>{t('ssh.connections.disable')}</button>
|
||||
)}
|
||||
<button onClick={() => setReasonForOp({ kind: 'delete', conn: c })} className={btnDangerCls}>削除</button>
|
||||
<button onClick={() => setReasonForOp({ kind: 'delete', conn: c })} className={btnDangerCls}>{t('ssh.connections.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{editingId === c.id && (
|
||||
@@ -304,10 +304,10 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
|
||||
{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}`
|
||||
reasonForOp.kind === 'delete' ? t('ssh.connections.opTitle.delete', { label: reasonForOp.conn.label }) :
|
||||
reasonForOp.kind === 'disable' ? t('ssh.connections.opTitle.disable', { label: reasonForOp.conn.label }) :
|
||||
reasonForOp.kind === 'enable' ? t('ssh.connections.opTitle.enable', { label: reasonForOp.conn.label }) :
|
||||
t('ssh.connections.opTitle.forceUnlock', { label: reasonForOp.conn.label })
|
||||
}
|
||||
warning={reasonForOp.kind === 'delete'}
|
||||
onCancel={() => setReasonForOp(null)}
|
||||
@@ -351,6 +351,7 @@ interface ReasonModalProps {
|
||||
}
|
||||
|
||||
function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [reason, setReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -383,19 +384,19 @@ function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
className="w-full text-xs px-2 py-1.5 border border-hairline rounded"
|
||||
placeholder="監査ログに残す理由を記述"
|
||||
placeholder={t('ssh.common.reasonModalPlaceholder')}
|
||||
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={onCancel} disabled={submitting} className={btnCls}>{t('ssh.common.cancel')}</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 ? '送信中…' : '実行'}
|
||||
{submitting ? t('ssh.common.submitting') : t('ssh.common.run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,6 +412,7 @@ const btnDangerCls = 'px-2 h-7 text-2xs text-red-600 border border-hairline roun
|
||||
* that ask "give me the connection_id" can be answered by clicking once.
|
||||
*/
|
||||
function CopyableUuid({ value }: { value: string }) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [copied, setCopied] = useState(false);
|
||||
async function copy() {
|
||||
try {
|
||||
@@ -425,10 +427,10 @@ function CopyableUuid({ value }: { value: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={`クリックで UUID をコピー: ${value}`}
|
||||
title={t('ssh.connections.copyTooltip', { value })}
|
||||
className="font-mono hover:underline cursor-pointer text-slate-600 hover:text-accent-deep"
|
||||
>
|
||||
{copied ? '✓ コピーしました' : value}
|
||||
{copied ? t('ssh.connections.copied') : value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SshConnection, SshGrant, SshGrantSubjectType } from '../../lib/ssh-types';
|
||||
|
||||
@@ -68,6 +69,7 @@ interface Props {
|
||||
* UI groups grants by global connection so it's easy to see who can use what.
|
||||
*/
|
||||
export function SshGrantsForm({ showToast }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
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 });
|
||||
@@ -80,7 +82,7 @@ export function SshGrantsForm({ showToast }: Props) {
|
||||
mutationFn: (body: Record<string, unknown>) => postJson('/api/ssh/admin/grants', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'grants'] });
|
||||
showToast?.('Grant を作成しました', 'success');
|
||||
showToast?.(t('ssh.grants.toast.created'), 'success');
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
@@ -89,7 +91,7 @@ export function SshGrantsForm({ showToast }: Props) {
|
||||
deleteJson(`/api/ssh/admin/grants/${encodeURIComponent(id)}`, { reason }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'grants'] });
|
||||
showToast?.('Grant を削除しました', 'success');
|
||||
showToast?.(t('ssh.grants.toast.deleted'), 'success');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,14 +106,10 @@ export function SshGrantsForm({ showToast }: Props) {
|
||||
if (sshDisabled) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">アクセス権 (grants)</h3>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.grants.title')}</h3>
|
||||
<div className="border border-amber-200 dark:border-amber-500/30 rounded-md bg-amber-50 dark:bg-amber-500/15 p-4 text-xs text-amber-900 dark:text-amber-300">
|
||||
<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 className="font-semibold mb-1">{t('ssh.grants.disabledTitle')}</div>
|
||||
<div>{t('ssh.grants.disabledBody')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -120,19 +118,19 @@ export function SshGrantsForm({ showToast }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-900">アクセス権 (grants)</h3>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.grants.title')}</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 を発行
|
||||
{t('ssh.grants.issueButton')}
|
||||
</button>
|
||||
</div>
|
||||
{globalConns.length === 0 && (
|
||||
<div className="text-xs text-slate-400 px-3 py-2">
|
||||
まずグローバル接続を登録してから grant を発行できます。
|
||||
{t('ssh.grants.needConnFirst')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -157,10 +155,10 @@ export function SshGrantsForm({ showToast }: Props) {
|
||||
{c.username}@{c.host}:{c.port}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-2xs text-slate-500 font-mono">{grants.length} grants</span>
|
||||
<span className="text-2xs text-slate-500 font-mono">{t('ssh.grants.grantsCount', { count: grants.length })}</span>
|
||||
</header>
|
||||
{grants.length === 0 ? (
|
||||
<div className="px-3 py-3 text-2xs text-slate-400">grant がありません — ユーザーは利用できません。</div>
|
||||
<div className="px-3 py-3 text-2xs text-slate-400">{t('ssh.grants.noGrants')}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{grants.map(g => (
|
||||
@@ -177,15 +175,15 @@ export function SshGrantsForm({ showToast }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 mt-0.5">
|
||||
理由: {g.reason}
|
||||
{g.expiresAt && <> · 失効: <span className="font-mono">{g.expiresAt}</span></>}
|
||||
{t('ssh.common.reasonLabel', { reason: g.reason })}
|
||||
{g.expiresAt && <> · {t('ssh.grants.expiresInline', { at: g.expiresAt })}</>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReasonForDelete(g)}
|
||||
className="px-2 h-6 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50 dark:hover:bg-red-500/15"
|
||||
>
|
||||
取消
|
||||
{t('ssh.grants.revoke')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -198,7 +196,7 @@ export function SshGrantsForm({ showToast }: Props) {
|
||||
|
||||
{reasonForDelete && (
|
||||
<ReasonModal
|
||||
title={`Grant を取り消す`}
|
||||
title={t('ssh.grants.revokeTitle')}
|
||||
warning
|
||||
onCancel={() => setReasonForDelete(null)}
|
||||
onSubmit={async (reason) => {
|
||||
@@ -220,6 +218,7 @@ interface CreateGrantFormProps {
|
||||
}
|
||||
|
||||
function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGrantFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [connectionId, setConnectionId] = useState(connections[0]?.id ?? '');
|
||||
const [subjectType, setSubjectType] = useState<SshGrantSubjectType>('user');
|
||||
const [subjectId, setSubjectId] = useState('');
|
||||
@@ -263,16 +262,16 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border border-accent/40 rounded-md bg-canvas p-4 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-slate-700">Grant を発行</h4>
|
||||
<h4 className="text-xs font-semibold text-slate-700">{t('ssh.grants.createTitle')}</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>
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.globalConnection')}</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="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.subject')}</div>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={subjectType}
|
||||
@@ -286,7 +285,7 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={e => setSubjectId(e.target.value)}
|
||||
placeholder={subjectType === 'user' ? 'gitea ユーザー ID' : 'org ID'}
|
||||
placeholder={subjectType === 'user' ? t('ssh.grants.subjectUserPlaceholder') : t('ssh.grants.subjectOrgPlaceholder')}
|
||||
className="flex-1 min-w-0 text-xs px-2 py-1.5 border border-hairline rounded font-mono"
|
||||
required
|
||||
/>
|
||||
@@ -302,20 +301,20 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">すべてのピースで利用可能 (applies_to_all_pieces)</span>
|
||||
<span className="font-semibold">{t('ssh.grants.appliesAll')}</span>
|
||||
<span className="block text-2xs text-amber-700 dark:text-amber-300">
|
||||
⚠️ この grant は任意の piece からこの接続を使えるようにします。本当に必要なときのみ。
|
||||
{t('ssh.grants.appliesAllWarn')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{!appliesToAll && (
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Piece name</div>
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.pieceName')}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={pieceName}
|
||||
onChange={e => setPieceName(e.target.value)}
|
||||
placeholder="piece 名 (例: db-maintenance)"
|
||||
placeholder={t('ssh.grants.pieceNamePlaceholder')}
|
||||
className={inputCls + ' font-mono'}
|
||||
list="ssh-grant-piece-list"
|
||||
required
|
||||
@@ -328,7 +327,7 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
|
||||
</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>
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.expiresLabel')}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={expiresAt}
|
||||
@@ -343,7 +342,7 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="運用上の理由"
|
||||
placeholder={t('ssh.grants.reasonOpPlaceholder')}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
@@ -352,10 +351,10 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
|
||||
{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-canvas rounded-md hover:bg-surface disabled:opacity-50">
|
||||
キャンセル
|
||||
{t('ssh.common.cancel')}
|
||||
</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 ? '発行中…' : '発行'}
|
||||
{submitting ? t('ssh.grants.issuing') : t('ssh.grants.issue')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -370,6 +369,7 @@ interface ReasonModalProps {
|
||||
}
|
||||
|
||||
function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [reason, setReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -396,19 +396,19 @@ function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
className="w-full text-xs px-2 py-1.5 border border-hairline rounded"
|
||||
placeholder="監査ログに残す理由を記述"
|
||||
placeholder={t('ssh.common.reasonModalPlaceholder')}
|
||||
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={onCancel} disabled={submitting} className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50">{t('ssh.common.cancel')}</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 ? '送信中…' : '実行'}
|
||||
{submitting ? t('ssh.common.submitting') : t('ssh.common.run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface RotationStub {
|
||||
@@ -53,6 +54,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SshMasterKeyRotationForm({ showToast }: Props) {
|
||||
const { t } = useTranslation('settings');
|
||||
const qc = useQueryClient();
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
||||
const [showStartDialog, setShowStartDialog] = useState(false);
|
||||
@@ -70,11 +72,11 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
|
||||
onSuccess: (resp) => {
|
||||
setActiveJobId(resp.jobId);
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'rotation'] });
|
||||
showToast?.(`Rotation job 開始: ${resp.jobId}`, 'success');
|
||||
showToast?.(t('ssh.rotation.toast.started', { jobId: resp.jobId }), 'success');
|
||||
setShowStartDialog(false);
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : 'Rotation 開始失敗', 'error');
|
||||
showToast?.(e instanceof Error ? e.message : t('ssh.rotation.toast.startFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,23 +85,21 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Master Key Rotation</h3>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.rotation.title')}</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>
|
||||
{t('ssh.rotation.desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-hairline bg-canvas 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-2xs font-semibold text-slate-500 uppercase tracking-wide">{t('ssh.rotation.currentState')}</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 && <span>{t('ssh.rotation.idle')}</span>}
|
||||
{activeJobId !== null && statusQuery.isLoading && <span className="text-slate-400">{t('ssh.rotation.checking')}</span>}
|
||||
{activeJobId !== null && status === null && (
|
||||
<span className="text-emerald-700 dark:text-emerald-300">job {activeJobId} は完了またはクリア済み</span>
|
||||
<span className="text-emerald-700 dark:text-emerald-300">{t('ssh.rotation.jobDoneOrCleared', { jobId: activeJobId })}</span>
|
||||
)}
|
||||
{status && (
|
||||
<>
|
||||
@@ -113,7 +113,7 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
|
||||
)}
|
||||
</div>
|
||||
{status?.startedAt && (
|
||||
<div className="text-2xs text-slate-500 mt-0.5">開始: {status.startedAt}</div>
|
||||
<div className="text-2xs text-slate-500 mt-0.5">{t('ssh.rotation.startedAt', { ts: status.startedAt })}</div>
|
||||
)}
|
||||
{status?.progress?.note && (
|
||||
<div className="text-2xs text-slate-500 mt-0.5">{status.progress.note}</div>
|
||||
@@ -125,7 +125,7 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
|
||||
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 を開始
|
||||
{t('ssh.rotation.startButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,6 +150,7 @@ function ConfirmDialog({
|
||||
onCancel: () => void;
|
||||
onSubmit: (reason: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [reason, setReason] = useState('');
|
||||
const [typed, setTyped] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -170,14 +171,13 @@ function ConfirmDialog({
|
||||
<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-surface rounded-md shadow-lg border border-amber-300 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15">
|
||||
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-300">⚠️ Master Key Rotation を開始</h3>
|
||||
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-300">{t('ssh.rotation.confirmTitle')}</h3>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<p className="text-xs text-slate-700 leading-relaxed">
|
||||
この操作は<strong>メンテナンスモードを有効化</strong>します。
|
||||
SSH 接続の作成・更新・削除・テストが一時的にすべて 503 を返します。
|
||||
{t('ssh.rotation.confirmBody1')}
|
||||
<br />
|
||||
⚠️ v1 では DEK 再ラップは未実装です。 メンテナンスを解除するには手動でフラグをクリアする必要があります。
|
||||
{t('ssh.rotation.confirmBody2')}
|
||||
</p>
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Reason (≥ 8 chars)</div>
|
||||
@@ -186,13 +186,13 @@ function ConfirmDialog({
|
||||
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 を新しい値に置き換えるため"
|
||||
placeholder={t('ssh.rotation.reasonPlaceholder')}
|
||||
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> と入力してください
|
||||
{t('ssh.rotation.typeToConfirm')}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
@@ -206,14 +206,14 @@ function ConfirmDialog({
|
||||
</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">
|
||||
キャンセル
|
||||
{t('ssh.common.cancel')}
|
||||
</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 を開始'}
|
||||
{submitting ? t('ssh.rotation.starting') : t('ssh.rotation.startButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface StringArrayEditorProps {
|
||||
value: string[];
|
||||
@@ -7,6 +8,7 @@ interface StringArrayEditorProps {
|
||||
}
|
||||
|
||||
export function StringArrayEditor({ value, onChange, placeholder }: StringArrayEditorProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -34,7 +36,7 @@ export function StringArrayEditor({ value, onChange, placeholder }: StringArrayE
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep"
|
||||
>
|
||||
追加
|
||||
{t('stringArray.add')}
|
||||
</button>
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolList } from '../../hooks/useTools';
|
||||
import type { ToolCatalogEntry } from '../../api';
|
||||
import { HelpText } from './HelpText';
|
||||
@@ -26,6 +27,7 @@ export interface ToolTagInputProps {
|
||||
* preparing for a server that's about to come back online).
|
||||
*/
|
||||
export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInputProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const { data: catalog } = useToolList();
|
||||
const [input, setInput] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
@@ -143,7 +145,7 @@ export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInput
|
||||
}}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
|
||||
placeholder={value.length === 0 ? t('tools.tagInput.placeholder') : ''}
|
||||
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
|
||||
/>
|
||||
)}
|
||||
@@ -181,10 +183,7 @@ export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInput
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<HelpText>
|
||||
ここに列挙したツールのみ LLM に提示されます。
|
||||
オフラインの MCP ツールや未知のツールも自動削除されず、明示的に削除するまで残ります。
|
||||
</HelpText>
|
||||
<HelpText>{t('tools.tagInput.help')}</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,6 +212,7 @@ function SelectedToolChip({
|
||||
onRemove: () => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const isUnknown = !entry;
|
||||
const isUnavailable = entry ? !entry.available : false;
|
||||
// Visual stack:
|
||||
@@ -224,7 +224,7 @@ function SelectedToolChip({
|
||||
? 'bg-amber-50 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-200 dark:border-amber-500/30'
|
||||
: 'bg-slate-100 text-slate-700';
|
||||
const tip = isUnknown
|
||||
? 'このツールは現在のカタログに存在しません。明示削除するまで保持されます。'
|
||||
? t('tools.tagInput.unknownTip')
|
||||
: isUnavailable
|
||||
? (entry?.reason ?? 'unavailable')
|
||||
: undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
@@ -21,6 +22,7 @@ import type { SectionFormProps } from './types';
|
||||
* here — see PathsStorageForm.
|
||||
*/
|
||||
export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
@@ -34,21 +36,21 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
<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>
|
||||
<HelpText>{t('tools.x.authTokenHelp')}</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>
|
||||
<HelpText>{t('tools.x.ct0Help')}</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>
|
||||
<HelpText>{t('tools.x.cliHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.x.timeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xTimeout ?? 90}
|
||||
onChange={v => onChange('tools.xTimeout', Number(v))} />
|
||||
</div>
|
||||
@@ -61,40 +63,40 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldLabel>X Chrome Profile</FieldLabel>
|
||||
<FieldInput value={tools.xChromeProfile ?? ''} onChange={v => onChange('tools.xChromeProfile', v)}
|
||||
placeholder="/path/to/chrome/profile" />
|
||||
<HelpText>Cookie 抽出用の Chrome プロファイルディレクトリ。</HelpText>
|
||||
<HelpText>{t('tools.x.chromeProfileHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Media Download</FieldLabel>
|
||||
<select value={tools.xDownloadMedia ?? 'auto'}
|
||||
onChange={e => onChange('tools.xDownloadMedia', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
|
||||
<option value="auto">auto(画像/メディアを自動取得・既定)</option>
|
||||
<option value="never">never(取得しない)</option>
|
||||
<option value="auto">{t('tools.x.mediaAuto')}</option>
|
||||
<option value="never">{t('tools.x.mediaNever')}</option>
|
||||
</select>
|
||||
<HelpText>X 投稿の画像等メディアの自動ダウンロード。デフォルト: auto</HelpText>
|
||||
<HelpText>{t('tools.x.mediaHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Video Download</FieldLabel>
|
||||
<select value={tools.xDownloadVideo ?? 'thumbnail'}
|
||||
onChange={e => onChange('tools.xDownloadVideo', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
|
||||
<option value="thumbnail">thumbnail(サムネイルのみ・既定)</option>
|
||||
<option value="full">full(動画本体を取得)</option>
|
||||
<option value="never">never(取得しない)</option>
|
||||
<option value="thumbnail">{t('tools.x.videoThumbnail')}</option>
|
||||
<option value="full">{t('tools.x.videoFull')}</option>
|
||||
<option value="never">{t('tools.x.videoNever')}</option>
|
||||
</select>
|
||||
<HelpText>X 投稿の動画の取得モード。デフォルト: thumbnail(帯域節約)</HelpText>
|
||||
<HelpText>{t('tools.x.videoHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Media Max (MB)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xMediaMaxMb ?? ''}
|
||||
onChange={v => onChange('tools.xMediaMaxMb', v ? Number(v) : undefined)} />
|
||||
<HelpText>1 メディアあたりの最大ダウンロードサイズ(MB)</HelpText>
|
||||
<HelpText>{t('tools.x.mediaMaxHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Media Fetch Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.x.mediaFetchTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xMediaFetchTimeoutSeconds ?? ''}
|
||||
onChange={v => onChange('tools.xMediaFetchTimeoutSeconds', v ? Number(v) : undefined)} />
|
||||
<HelpText>メディア取得のタイムアウト(秒)</HelpText>
|
||||
<HelpText>{t('tools.x.mediaFetchTimeoutHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -105,13 +107,13 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
<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>
|
||||
<HelpText>{t('tools.maps.keyHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Maps Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.maps.timeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.mapsTimeout ?? ''}
|
||||
onChange={v => onChange('tools.mapsTimeout', v ? Number(v) : undefined)} />
|
||||
<HelpText>Maps / Nominatim / OSRM 呼び出しのタイムアウト(秒)。デフォルト: 30</HelpText>
|
||||
<HelpText>{t('tools.maps.timeoutHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -123,12 +125,12 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldLabel>Amazon Affiliate Tag</FieldLabel>
|
||||
<FieldInput value={tools.amazonAffiliateTag ?? ''} onChange={v => onChange('tools.amazonAffiliateTag', v)}
|
||||
placeholder="your-tag-22" />
|
||||
<HelpText>SearchAmazon で使用するアソシエイトタグ。</HelpText>
|
||||
<HelpText>{t('tools.amazon.affiliateHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Keepa API Key</FieldLabel>
|
||||
<FieldInput type="password" value={tools.keepaApiKey ?? ''} onChange={v => onChange('tools.keepaApiKey', v)} />
|
||||
<HelpText>Keepa API キー(価格履歴データ取得用)。未設定でもグラフ画像リンクは提供されます。</HelpText>
|
||||
<HelpText>{t('tools.amazon.keepaHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -137,31 +139,25 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
User-supplied Scripts
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>RunUserScript を有効化</FieldLabel>
|
||||
<FieldLabel>{t('tools.userScripts.enableLabel')}</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>有効 (browser-macros: LLM の RunUserScript + scheduled script task が動作)</span>
|
||||
<span>{t('tools.userScripts.enabledToggle')}</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>
|
||||
<HelpText>{t('tools.userScripts.enableHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>実行許可ユーザー allowlist (空欄 = 全員)</FieldLabel>
|
||||
<FieldLabel>{t('tools.userScripts.allowlistLabel')}</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={tools.userScriptsAllowUserids ?? []}
|
||||
onChange={v => onChange('tools.userScriptsAllowUserids', v)}
|
||||
placeholder="user id (例: 12345)"
|
||||
placeholder={t('tools.userScripts.allowlistPlaceholder')}
|
||||
/>
|
||||
<HelpText>
|
||||
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ browser-macro の実行 (RunUserScript / scheduled script task) が許可される。
|
||||
</HelpText>
|
||||
<HelpText>{t('tools.userScripts.allowlistHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
@@ -31,6 +32,7 @@ interface ToolsFormProps extends SectionFormProps {
|
||||
}
|
||||
|
||||
export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const tools = config.tools ?? {};
|
||||
const tabsToShow = visibleTabs && visibleTabs.length > 0
|
||||
? TOOL_TABS.filter(t => visibleTabs.includes(t.id))
|
||||
@@ -49,7 +51,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<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="ツールカテゴリ">
|
||||
<nav className="flex flex-wrap gap-1 border-b border-hairline -mt-2" aria-label={t('tools.categoriesAria')}>
|
||||
{tabsToShow.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -72,15 +74,15 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<div>
|
||||
<FieldLabel>SearXNG URL</FieldLabel>
|
||||
<FieldInput value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
|
||||
<HelpText>WebSearch のフォールバック用 SearXNG エンドポイント。</HelpText>
|
||||
<HelpText>{t('tools.web.searxngHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebFetch Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.web.webfetchTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.webfetchTimeout ?? 30}
|
||||
onChange={v => onChange('tools.webfetchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebSearch Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.web.websearchTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.websearchTimeout ?? 15}
|
||||
onChange={v => onChange('tools.websearchTimeout', Number(v))} />
|
||||
</div>
|
||||
@@ -90,7 +92,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
value={tools.webfetchAllowedHosts ?? []}
|
||||
onChange={v => onChange('tools.webfetchAllowedHosts', v)}
|
||||
placeholder="hostname or IP address" />
|
||||
<HelpText>SSRF 保護の例外ホスト名/IP アドレス。WebFetch・BrowseWeb のすべてに適用。</HelpText>
|
||||
<HelpText>{t('tools.web.ssrfHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -100,16 +102,16 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<div>
|
||||
<FieldLabel>Vision Model</FieldLabel>
|
||||
<FieldInput value={tools.visionModel ?? ''} onChange={v => onChange('tools.visionModel', v)} />
|
||||
<HelpText>画像分析に使用するモデル名(例: qwen2-vl:8b-instruct)</HelpText>
|
||||
<HelpText>{t('tools.vision.modelHelp')}</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>
|
||||
placeholder={t('tools.vision.baseUrlPlaceholder')} />
|
||||
<HelpText>{t('tools.vision.baseUrlHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.vision.timeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.visionTimeout ?? 60}
|
||||
onChange={v => onChange('tools.visionTimeout', Number(v))} />
|
||||
</div>
|
||||
@@ -122,7 +124,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<FieldLabel>OCR Model</FieldLabel>
|
||||
<FieldInput value={tools.ocrModel ?? ''} onChange={v => onChange('tools.ocrModel', v)}
|
||||
placeholder="glm-ocr" />
|
||||
<HelpText>GLM-OCR で使用するモデル名。</HelpText>
|
||||
<HelpText>{t('tools.vision.ocrModelHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -132,21 +134,21 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<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>
|
||||
<HelpText>{t('tools.x.authTokenHelp')}</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>
|
||||
<HelpText>{t('tools.x.ct0Help')}</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>
|
||||
<HelpText>{t('tools.x.cliHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.x.timeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xTimeout ?? 90}
|
||||
onChange={v => onChange('tools.xTimeout', Number(v))} />
|
||||
</div>
|
||||
@@ -159,7 +161,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<FieldLabel>X Chrome Profile</FieldLabel>
|
||||
<FieldInput value={tools.xChromeProfile ?? ''} onChange={v => onChange('tools.xChromeProfile', v)}
|
||||
placeholder="/path/to/chrome/profile" />
|
||||
<HelpText>Cookie 抽出用の Chrome プロファイルディレクトリ。</HelpText>
|
||||
<HelpText>{t('tools.x.chromeProfileHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -169,7 +171,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<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>
|
||||
<HelpText>{t('tools.maps.keyHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -180,12 +182,12 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<FieldLabel>Amazon Affiliate Tag</FieldLabel>
|
||||
<FieldInput value={tools.amazonAffiliateTag ?? ''} onChange={v => onChange('tools.amazonAffiliateTag', v)}
|
||||
placeholder="your-tag-22" />
|
||||
<HelpText>SearchAmazon で使用するアソシエイトタグ。</HelpText>
|
||||
<HelpText>{t('tools.amazon.affiliateHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Keepa API Key</FieldLabel>
|
||||
<FieldInput type="password" value={tools.keepaApiKey ?? ''} onChange={v => onChange('tools.keepaApiKey', v)} />
|
||||
<HelpText>Keepa API キー(価格履歴データ取得用)。未設定でもグラフ画像リンクは提供されます。</HelpText>
|
||||
<HelpText>{t('tools.amazon.keepaHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -196,19 +198,19 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<FieldLabel>Speech Server URL</FieldLabel>
|
||||
<FieldInput value={tools.speechServerUrl ?? ''} onChange={v => onChange('tools.speechServerUrl', v)}
|
||||
placeholder="http://localhost:8000/v1" />
|
||||
<HelpText>音声認識サーバーの API エンドポイント(TranscribeAudio 用)</HelpText>
|
||||
<HelpText>{t('tools.speech.serverHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.speech.timeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.speechTimeout ?? 300}
|
||||
onChange={v => onChange('tools.speechTimeout', Number(v))} />
|
||||
<HelpText>長い音声ファイルに対応するためのタイムアウト</HelpText>
|
||||
<HelpText>{t('tools.speech.timeoutHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Language</FieldLabel>
|
||||
<FieldInput value={tools.speechLanguage ?? 'ja'} onChange={v => onChange('tools.speechLanguage', v)}
|
||||
placeholder="ja" />
|
||||
<HelpText>文字起こしのデフォルト言語コード</HelpText>
|
||||
<HelpText>{t('tools.speech.languageHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -219,7 +221,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
<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 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30"
|
||||
title="この機能は legacy です。新規の知識検索統合は MCP server 経由を推奨"
|
||||
title={t('tools.knowledge.legacyBadgeTitle')}
|
||||
>
|
||||
LEGACY
|
||||
</span>
|
||||
@@ -228,23 +230,21 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
role="note"
|
||||
className="rounded border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300"
|
||||
>
|
||||
DKS 機能は <strong>legacy</strong> 化されており、新規の知識検索統合は{' '}
|
||||
<strong>MCP server 経由</strong> を推奨します。既存の namespace 設定は引き続き動作しますが、
|
||||
新規 namespace の追加はできません。{' '}
|
||||
{t('tools.knowledge.note')}{' '}
|
||||
<a
|
||||
href="/help"
|
||||
className="underline text-amber-900 dark:text-amber-300 hover:text-amber-700 dark:hover:text-amber-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MCP 連携ガイドを開く
|
||||
{t('tools.knowledge.mcpGuideLink')}
|
||||
</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>
|
||||
<HelpText>{t('tools.knowledge.serviceUrlHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Knowledge Namespaces</FieldLabel>
|
||||
@@ -252,10 +252,10 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
value={tools.knowledgeNamespaces ?? {}}
|
||||
onChange={v => onChange('tools.knowledgeNamespaces', v)}
|
||||
addDisabled
|
||||
addDisabledReason="新規 namespace 追加は MCP 経由を推奨"
|
||||
addDisabledReason={t('tools.knowledge.addDisabledReason')}
|
||||
addDisabledHref="/help"
|
||||
/>
|
||||
<HelpText>DKS の名前空間と API キーの組み合わせ。既存項目の編集・削除は可能ですが、新規追加は無効化されています。</HelpText>
|
||||
<HelpText>{t('tools.knowledge.namespacesHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,93 +263,79 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
||||
{tab === 'user-folder' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>RunUserScript を有効化</FieldLabel>
|
||||
<FieldLabel>{t('tools.userScripts.enableLabel')}</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>有効 (browser-macros: LLM の RunUserScript + scheduled script task が動作)</span>
|
||||
<span>{t('tools.userScripts.enabledToggle')}</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>
|
||||
<HelpText>{t('tools.userScripts.enableHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>実行許可ユーザー allowlist (空欄 = 全員)</FieldLabel>
|
||||
<FieldLabel>{t('tools.userScripts.allowlistLabel')}</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={tools.userScriptsAllowUserids ?? []}
|
||||
onChange={v => onChange('tools.userScriptsAllowUserids', v)}
|
||||
placeholder="user id (例: 12345)"
|
||||
placeholder={t('tools.userScripts.allowlistPlaceholder')}
|
||||
/>
|
||||
<HelpText>
|
||||
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ browser-macro の実行 (RunUserScript / scheduled script task) が許可される。
|
||||
</HelpText>
|
||||
<HelpText>{t('tools.userScripts.allowlistHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Trash Retention (日)</FieldLabel>
|
||||
<FieldLabel>{t('tools.userScripts.trashRetentionLabel')}</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>
|
||||
<HelpText>{t('tools.userScripts.trashRetentionHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'uploads' && (
|
||||
<div className="space-y-5">
|
||||
<HelpText>UI からのアップロード API のリクエスト body 上限 (MB)</HelpText>
|
||||
<HelpText>{t('tools.uploads.sectionHelp')}</HelpText>
|
||||
<div>
|
||||
<FieldLabel>タスク作成・コメント時の最大アップロードサイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.uploads.maxLabel')}</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>
|
||||
<HelpText>{t('tools.uploads.maxHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'office' && (
|
||||
<div className="space-y-5">
|
||||
<HelpText>Office ファイルサイズ上限 (MB)</HelpText>
|
||||
<HelpText>{t('tools.office.sectionHelp')}</HelpText>
|
||||
<div>
|
||||
<FieldLabel>ReadExcel 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.excelLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeExcelMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeExcelMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadExcel が受け付ける .xlsx / .xls ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.excelHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadDocx 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.docxLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeDocxMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeDocxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadDocx が受け付ける .docx ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.docxHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPdf 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.pdfLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePdfMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officePdfMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPdf が受け付ける .pdf ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.pdfHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.pptxLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxSizeMb ?? 50}
|
||||
onChange={v => onChange('tools.officePptxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.pptxHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 展開後サイズ上限</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.pptxUncompressedLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxUncompressedMb ?? 200}
|
||||
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
|
||||
<HelpText>PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
@@ -16,6 +17,7 @@ import type { SectionFormProps } from './types';
|
||||
* tools.task_upload_max_size_mb
|
||||
*/
|
||||
export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
@@ -29,16 +31,16 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
<div>
|
||||
<FieldLabel>Vision Model</FieldLabel>
|
||||
<FieldInput value={tools.visionModel ?? ''} onChange={v => onChange('tools.visionModel', v)} />
|
||||
<HelpText>画像分析に使用するモデル名(例: qwen2-vl:8b-instruct)</HelpText>
|
||||
<HelpText>{t('tools.vision.modelHelp')}</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>
|
||||
placeholder={t('tools.vision.baseUrlPlaceholder')} />
|
||||
<HelpText>{t('tools.vision.baseUrlHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Vision Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.vision.timeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.visionTimeout ?? 60}
|
||||
onChange={v => onChange('tools.visionTimeout', Number(v))} />
|
||||
</div>
|
||||
@@ -51,7 +53,7 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldLabel>OCR Model</FieldLabel>
|
||||
<FieldInput value={tools.ocrModel ?? ''} onChange={v => onChange('tools.ocrModel', v)}
|
||||
placeholder="glm-ocr" />
|
||||
<HelpText>GLM-OCR で使用するモデル名。</HelpText>
|
||||
<HelpText>{t('tools.vision.ocrModelHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -63,19 +65,19 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldLabel>Speech Server URL</FieldLabel>
|
||||
<FieldInput value={tools.speechServerUrl ?? ''} onChange={v => onChange('tools.speechServerUrl', v)}
|
||||
placeholder="http://localhost:8000/v1" />
|
||||
<HelpText>音声認識サーバーの API エンドポイント(TranscribeAudio 用)</HelpText>
|
||||
<HelpText>{t('tools.speech.serverHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.speech.timeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.speechTimeout ?? 300}
|
||||
onChange={v => onChange('tools.speechTimeout', Number(v))} />
|
||||
<HelpText>長い音声ファイルに対応するためのタイムアウト</HelpText>
|
||||
<HelpText>{t('tools.speech.timeoutHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Speech Language</FieldLabel>
|
||||
<FieldInput value={tools.speechLanguage ?? 'ja'} onChange={v => onChange('tools.speechLanguage', v)}
|
||||
placeholder="ja" />
|
||||
<HelpText>文字起こしのデフォルト言語コード</HelpText>
|
||||
<HelpText>{t('tools.speech.languageHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -83,36 +85,36 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Office (file size limits)
|
||||
</h3>
|
||||
<HelpText>Office ファイルサイズ上限 (MB)</HelpText>
|
||||
<HelpText>{t('tools.office.sectionHelp')}</HelpText>
|
||||
<div>
|
||||
<FieldLabel>ReadExcel 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.excelLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeExcelMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeExcelMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadExcel が受け付ける .xlsx / .xls ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.excelHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadDocx 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.docxLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeDocxMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officeDocxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadDocx が受け付ける .docx ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.docxHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPdf 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.pdfLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePdfMaxSizeMb ?? 10}
|
||||
onChange={v => onChange('tools.officePdfMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPdf が受け付ける .pdf ファイルの最大サイズ(デフォルト: 10 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.pdfHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 最大サイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.pptxLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxSizeMb ?? 50}
|
||||
onChange={v => onChange('tools.officePptxMaxSizeMb', Number(v))} />
|
||||
<HelpText>ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.pptxHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>ReadPPTX 展開後サイズ上限</FieldLabel>
|
||||
<FieldLabel>{t('tools.office.pptxUncompressedLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officePptxMaxUncompressedMb ?? 200}
|
||||
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
|
||||
<HelpText>PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)</HelpText>
|
||||
<HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -120,18 +122,12 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Uploads
|
||||
</h3>
|
||||
<HelpText>UI からのアップロード API のリクエスト body 上限 (MB)</HelpText>
|
||||
<HelpText>{t('tools.uploads.sectionHelp')}</HelpText>
|
||||
<div>
|
||||
<FieldLabel>タスク作成・コメント時の最大アップロードサイズ</FieldLabel>
|
||||
<FieldLabel>{t('tools.uploads.maxLabel')}</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>
|
||||
<HelpText>{t('tools.uploads.maxHelpStorage')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
@@ -18,6 +19,7 @@ import type { SectionFormProps } from './types';
|
||||
* search_filter.auto_block.*
|
||||
*/
|
||||
export function ToolsWebForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const tools = config.tools ?? {};
|
||||
const sf = config.searchFilter ?? {};
|
||||
const autoBlock = sf.autoBlock ?? {};
|
||||
@@ -37,15 +39,15 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
|
||||
<div>
|
||||
<FieldLabel>SearXNG URL</FieldLabel>
|
||||
<FieldInput value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
|
||||
<HelpText>WebSearch のフォールバック用 SearXNG エンドポイント。</HelpText>
|
||||
<HelpText>{t('tools.web.searxngHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebFetch Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.web.webfetchTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.webfetchTimeout ?? 30}
|
||||
onChange={v => onChange('tools.webfetchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>WebSearch Timeout (秒)</FieldLabel>
|
||||
<FieldLabel>{t('tools.web.websearchTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.websearchTimeout ?? 15}
|
||||
onChange={v => onChange('tools.websearchTimeout', Number(v))} />
|
||||
</div>
|
||||
@@ -55,7 +57,7 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
|
||||
value={tools.webfetchAllowedHosts ?? []}
|
||||
onChange={v => onChange('tools.webfetchAllowedHosts', v)}
|
||||
placeholder="hostname or IP address" />
|
||||
<HelpText>SSRF 保護の例外ホスト名/IP アドレス。WebFetch・BrowseWeb のすべてに適用。</HelpText>
|
||||
<HelpText>{t('tools.web.ssrfHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -65,23 +67,23 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Blocked Patterns (ブロックパターン)</FieldLabel>
|
||||
<FieldLabel>{t('tools.web.blockedLabel')}</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={sf.blockedPatterns ?? []}
|
||||
onChange={v => onChange('searchFilter.blockedPatterns', v)}
|
||||
placeholder="regex pattern"
|
||||
/>
|
||||
<HelpText>WebSearch クエリからフィルタするパターン(正規表現)。</HelpText>
|
||||
<HelpText>{t('tools.web.blockedHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Auto Block (自動ブロック)</FieldLabel>
|
||||
<FieldLabel>{t('tools.web.autoBlockLabel')}</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{([
|
||||
['privateIp', 'プライベートIP', autoBlock.privateIp],
|
||||
['internalDomain', '内部ドメイン', autoBlock.internalDomain],
|
||||
['email', 'メールアドレス', autoBlock.email],
|
||||
['phone', '電話番号', autoBlock.phone],
|
||||
['privateIp', t('tools.web.autoBlock.privateIp'), autoBlock.privateIp],
|
||||
['internalDomain', t('tools.web.autoBlock.internalDomain'), autoBlock.internalDomain],
|
||||
['email', t('tools.web.autoBlock.email'), autoBlock.email],
|
||||
['phone', t('tools.web.autoBlock.phone'), autoBlock.phone],
|
||||
] as const).map(([key, label, checked]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
@@ -94,7 +96,7 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>検索クエリに含まれる機密情報を自動でブロック。</HelpText>
|
||||
<HelpText>{t('tools.web.autoBlockHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Workspace</h2>
|
||||
@@ -13,17 +15,17 @@ export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
value={config.worktreeDir ?? ''}
|
||||
onChange={v => onChange('worktreeDir', v)}
|
||||
disabled={!!overriddenByEnv['worktreeDir']}
|
||||
disabledReason="WORKTREE_DIR 環境変数で上書き中"
|
||||
disabledReason={t('pathsStorage.worktreeOverride')}
|
||||
/>
|
||||
{overriddenByEnv['worktreeDir'] && <EnvOverrideWarning />}
|
||||
<HelpText>ジョブ実行時の作業ディレクトリのベースパス</HelpText>
|
||||
<HelpText>{t('pathsStorage.worktreeHelp')}</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>
|
||||
<HelpText>{t('pathsStorage.customPiecesHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -33,16 +35,16 @@ export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
value={config.concurrency ?? ''}
|
||||
onChange={v => onChange('concurrency', v ? Number(v) : undefined)}
|
||||
disabled={!!overriddenByEnv['concurrency']}
|
||||
disabledReason="CONCURRENCY 環境変数で上書き中"
|
||||
disabledReason={t('execution.concurrencyOverride')}
|
||||
/>
|
||||
{overriddenByEnv['concurrency'] && <EnvOverrideWarning />}
|
||||
<HelpText>同時実行可能なジョブ数</HelpText>
|
||||
<HelpText>{t('execution.concurrencyHelp')}</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>
|
||||
<HelpText>{t('execution.maxMovementsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Retry</h3>
|
||||
@@ -51,14 +53,14 @@ export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
<FieldLabel>Max Attempts</FieldLabel>
|
||||
<FieldInput type="number" value={config.retry?.maxAttempts ?? 3}
|
||||
onChange={v => onChange('retry.maxAttempts', Number(v))} />
|
||||
<HelpText>ジョブ失敗時の最大リトライ回数。デフォルト: 3</HelpText>
|
||||
<HelpText>{t('execution.maxAttemptsHelp')}</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>
|
||||
<HelpText>{t('execution.backoffHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function EnvOverrideWarning() {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="text-2xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 px-2 py-1 rounded mt-1">
|
||||
環境変数で上書きされています(保存しても反映されません)
|
||||
{t('formUtils.envOverride')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user