This commit is contained in:
@@ -56,7 +56,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `${label}-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
const caseRow = rail
|
||||
|
||||
+33
-40
@@ -59,7 +59,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `${label}-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
const caseRow = rail
|
||||
@@ -190,7 +190,7 @@ test('spaces foundation: rail, create case space, open detail tabs', async ({ pa
|
||||
const caseTitle = `E2E案件-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
|
||||
// 5. The new case space appears in the rail (and dialog closes).
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
@@ -242,7 +242,7 @@ test('spaces mobile: rail <-> detail switch with back control at 390px', async (
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `E2Eモバイル-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
// A space is open: detail is now the visible full-width view, rail is hidden,
|
||||
@@ -301,7 +301,7 @@ test('space inline chat: create + open conversation without leaving for Tasks',
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `E2E会話-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
const caseRow = rail
|
||||
@@ -433,7 +433,7 @@ test('space files: icon grid, upload appears as tile, click opens preview modal'
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `E2Eファイル-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
const caseRow = rail
|
||||
@@ -617,7 +617,7 @@ test('space output link: chat output-path link opens preview modal', async ({ pa
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `E2Eリンク-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
const caseRow = rail
|
||||
@@ -724,7 +724,7 @@ test('space chat tabs: single in-place tab bar, overview in place, back to chat'
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `E2Eタブ-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
const caseRow = rail
|
||||
@@ -839,7 +839,7 @@ test('space settings tab: AGENTS.md per-space persistence + MCP/SSH panels rende
|
||||
await expect(titleInput).toBeVisible();
|
||||
const caseTitle = `E2E設定-${Date.now()}`;
|
||||
await titleInput.fill(caseTitle);
|
||||
await page.getByTestId('create-space-submit').click();
|
||||
await page.getByTestId('space-form-submit').click();
|
||||
await expect(titleInput).toHaveCount(0);
|
||||
|
||||
const caseRow = rail
|
||||
@@ -1292,46 +1292,39 @@ test('space chat CONTINUE: button gated on terminal job; opens ContinueWithPiece
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// 5. VISIBILITY — the toolbar visibility <select> (data-testid="space-chat-visibility")
|
||||
// drives updateLocalTask({ visibility }). Changing it to "public" persists;
|
||||
// after a full reload + reselecting the chat, the select retains "public".
|
||||
// ("org" is intentionally NOT used here: the server rejects org visibility
|
||||
// (400) unless the user belongs to the scoped org, and the no-auth synthetic
|
||||
// local user has no orgs — so "public" is the env-appropriate persistence
|
||||
// proof. The org branch is covered by backend visibility tests.)
|
||||
test('space chat VISIBILITY: change to public persists across reload', async ({ page }) => {
|
||||
// 5. VISIBILITY — space chats have a FIXED scope: always visible to the space's
|
||||
// members, never user-selectable. The visibility <select> was removed when the
|
||||
// scope became fixed, and the static note chip that replaced it was removed in
|
||||
// PR #780 (the scope is documented in help instead). This test guards that no
|
||||
// visibility control sneaks back into the conversation toolbar, while the
|
||||
// action buttons that DO belong there (share / delete) stay present — so the
|
||||
// assertion can't pass vacuously on a broken/empty toolbar. The auth-mode twin
|
||||
// of this guard lives in ui/e2e-auth/sharing-scope.auth.spec.ts. (History:
|
||||
// issue #782 — the old "change to public persists" test referenced the removed
|
||||
// testid and had been silently dead since the selector was retired.)
|
||||
test('space chat VISIBILITY: no visibility control (fixed member-only scope)', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2E可視性');
|
||||
const { conversation, taskId } = await startInlineChat(
|
||||
await createAndOpenCaseSpace(page, 'E2E可視性');
|
||||
const { conversation } = await startInlineChat(
|
||||
page,
|
||||
'E2E: 可視性テスト用チャット。最初のメッセージ。',
|
||||
);
|
||||
|
||||
const visibility = conversation.getByTestId('space-chat-visibility');
|
||||
await expect(visibility).toBeVisible();
|
||||
await expect(visibility).toHaveValue('private');
|
||||
// Positive anchor first: the toolbar actions are rendered, so the negative
|
||||
// assertions below are checked against a live, non-empty conversation UI.
|
||||
await expect(conversation.getByTestId('space-chat-share')).toBeVisible();
|
||||
await expect(conversation.getByTestId('space-chat-delete')).toBeVisible();
|
||||
|
||||
// Wait for the PATCH so we don't assert before the server commits the change.
|
||||
// (updateLocalTask uses PATCH /api/local/tasks/:id.)
|
||||
const patchPromise = page.waitForResponse(
|
||||
(r) => new RegExp(`/api/local/tasks/${taskId}$`).test(r.url()) && r.request().method() === 'PATCH',
|
||||
);
|
||||
await visibility.selectOption('public');
|
||||
const patchRes = await patchPromise;
|
||||
expect(patchRes.status()).toBe(200);
|
||||
await expect(visibility).toHaveValue('public');
|
||||
// No visibility selector, no visibility note — scope is fixed and implicit.
|
||||
await expect(conversation.getByTestId('space-chat-visibility')).toHaveCount(0);
|
||||
await expect(conversation.getByTestId('space-chat-visibility-note')).toHaveCount(0);
|
||||
|
||||
// Reload, reopen the same space + chat via URL state (space= selects the space,
|
||||
// chat= opens the inline conversation), and assert the select retained public
|
||||
// (proves server-side persistence, not just local component state).
|
||||
await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`);
|
||||
await expect(detail).toBeVisible();
|
||||
const visibilityAfter = page.getByTestId('space-conversation').getByTestId('space-chat-visibility');
|
||||
await expect(visibilityAfter).toBeVisible({ timeout: 15_000 });
|
||||
await expect(visibilityAfter).toHaveValue('public', { timeout: 15_000 });
|
||||
|
||||
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
|
||||
// Still on the spaces view with the chat selected. The current URL scheme
|
||||
// carries space= + chat= params (the legacy page=spaces param is gone; other
|
||||
// tests in this file still assert it and are stale — see issue #782 follow-up).
|
||||
await expect(page).toHaveURL(/[?&]space=[^&]+/);
|
||||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||||
expect(page.url()).not.toContain('page=tasks');
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,11 @@ export interface LocalTask {
|
||||
waitReason?: string | null;
|
||||
currentMovement?: string | null;
|
||||
currentActivity?: string | null;
|
||||
/** 現在の試行回数 (1 始まり)。>1 は中断→自動再試行が起きた印。 */
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
/** 直近の中断理由コード (context_overflow / llm_error 等)。 */
|
||||
abortReason?: string | null;
|
||||
workerId?: string | null;
|
||||
/**
|
||||
* Physical backend id (e.g. LiteLLM deployment) for jobs run through
|
||||
|
||||
@@ -198,7 +198,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
};
|
||||
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams } = useJobStream(task.id, jobStatus);
|
||||
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus);
|
||||
|
||||
// Most-recent content-field tool with decoded content to show live.
|
||||
const liveToolContent = useMemo(() => {
|
||||
@@ -287,6 +287,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-slate-900">{task.title}</h2>
|
||||
<span className="shrink-0 text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5 flex-shrink-0">
|
||||
{(task.latestJob?.attempt ?? 1) > 1 && jobStatus !== 'succeeded' && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15"
|
||||
title={task.latestJob?.abortReason
|
||||
? t('pane.attemptBadgeTitle', { reason: task.latestJob.abortReason })
|
||||
: undefined}
|
||||
data-testid="attempt-badge"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-amber-700 dark:text-amber-300">
|
||||
{t('pane.attemptBadge', { attempt: task.latestJob?.attempt, max: task.latestJob?.maxAttempts ?? 3 })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isBusy && (
|
||||
<div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${
|
||||
isWaitingSubtasks
|
||||
@@ -417,7 +430,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
{t('pane.agentResponding')}
|
||||
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
|
||||
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
|
||||
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
|
||||
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
|
||||
: t('pane.agentResponding')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -63,11 +63,11 @@ const CATEGORIES: Array<{ id: string; label: string; kinds: string[]; tone: stri
|
||||
{ id: 'run', label: 'Run', kinds: ['run_start', 'run_complete'], tone: 'bg-surface-2 text-slate-700 border-hairline' },
|
||||
{ id: 'movement', label: 'Movement', kinds: ['movement_start', 'movement_complete', 'transition', 'complete'], tone: 'bg-blue-50 text-blue-800 border-blue-100 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30' },
|
||||
{ id: 'tool', label: 'Tool', kinds: ['tool_call', 'tool_result'], tone: 'bg-canvas text-slate-700 border-hairline' },
|
||||
{ id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' },
|
||||
{ id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end', 'llm_call_retry'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' },
|
||||
{ id: 'cache', label: 'Cache', kinds: ['cache_set', 'cache_hit', 'cache_invalidate'], tone: 'bg-amber-50 text-amber-800 border-amber-100 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30' },
|
||||
{ id: 'memory', label: 'Memory', kinds: ['memory_invalidate', 'memory_update_call', 'memory_handoff_write', 'memory_handoff_read', 'memory_delta_write', 'memory_delta_absorb', 'memory_snapshot_written', 'memory_snapshot_failed'], tone: 'bg-emerald-50 text-emerald-800 border-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30' },
|
||||
{ id: 'watchdog', label: 'Watchdog', kinds: ['watchdog_fire', 'followup_detected'], tone: 'bg-red-50 text-red-800 border-red-100 dark:bg-red-500/15 dark:text-red-300 dark:border-red-500/30' },
|
||||
{ id: 'context', label: 'Context', kinds: ['context_action'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' },
|
||||
{ id: 'context', label: 'Context', kinds: ['context_action', 'context_recovery'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for AskSubtasksForm — focus on the newly surfaced
|
||||
* subtasks.spawnStaggerMs field.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { AskSubtasksForm } from './AskSubtasksForm';
|
||||
|
||||
describe('AskSubtasksForm', () => {
|
||||
it('shows spawnStaggerMs default (1000) in the last number field', () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<AskSubtasksForm config={{ subtasks: {} }} onChange={onChange} overriddenByEnv={{}} />);
|
||||
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
|
||||
expect(numbers[numbers.length - 1]).toHaveValue(1000);
|
||||
});
|
||||
|
||||
it('writes subtasks.spawnStaggerMs as a Number', async () => {
|
||||
const onChange = vi.fn();
|
||||
const { getConfig } = renderStatefulForm(AskSubtasksForm, { subtasks: { spawnStaggerMs: 1000 } }, { onChangeSpy: onChange });
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
const field = numbers[numbers.length - 1];
|
||||
fireEvent.change(field, { target: { value: '250' } });
|
||||
expect(getConfig().subtasks.spawnStaggerMs).toBe(250);
|
||||
expect(onChange).toHaveBeenCalledWith('subtasks.spawnStaggerMs', 250);
|
||||
});
|
||||
|
||||
it('serializes spawnStaggerMs as undefined when cleared', async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<AskSubtasksForm config={{ subtasks: { spawnStaggerMs: 1000 } }} onChange={onChange} overriddenByEnv={{}} />);
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
await userEvent.clear(numbers[numbers.length - 1]);
|
||||
expect(onChange).toHaveBeenLastCalledWith('subtasks.spawnStaggerMs', undefined);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,12 @@ export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldInput type="number" value={subtasks.maxPerParent ?? ''} onChange={v => onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} />
|
||||
<HelpText>{t('askSubtasks.maxPerParentHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Subtasks: Spawn Stagger (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={subtasks.spawnStaggerMs ?? 1000} onChange={v => onChange('subtasks.spawnStaggerMs', v === '' ? undefined : Number(v))} />
|
||||
<HelpText>{t('askSubtasks.spawnStaggerHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { MemoryLearningForm } from './MemoryLearningForm';
|
||||
import { MetricsForm } from './MetricsForm';
|
||||
import { ServerTlsForm } from './ServerTlsForm';
|
||||
import { ReflectionForm } from './ReflectionForm';
|
||||
import { SkillsQuotaForm } from './SkillsQuotaForm';
|
||||
import { McpForm } from './McpForm';
|
||||
import { SshForm } from './SshForm';
|
||||
import { GatewayServerForm } from './GatewayServerForm';
|
||||
@@ -242,6 +243,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
case 'context': return <ContextForm {...formProps} />;
|
||||
case 'safety': return <SafetyForm {...formProps} />;
|
||||
case 'reflection': return <ReflectionForm {...formProps} />;
|
||||
case 'skills-quota': return <SkillsQuotaForm {...formProps} />;
|
||||
case 'push-notifications': return <PushNotificationsForm {...formProps} />;
|
||||
case 'auth': return <AuthForm {...formProps} />;
|
||||
|
||||
|
||||
@@ -65,6 +65,17 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
|
||||
/>
|
||||
<HelpText>{t('execution.backoffHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 pt-3 border-t border-slate-200 space-y-2">
|
||||
<h3 className="text-sm font-medium text-slate-600">{t('execution.pythonPanel.title')}</h3>
|
||||
<div className="rounded-md border border-hairline bg-surface px-3 py-2.5 text-xs text-slate-600 dark:text-slate-300 space-y-1.5">
|
||||
<p>{t('execution.pythonPanel.intro')}</p>
|
||||
<p>{t('execution.pythonPanel.preinstalled')}</p>
|
||||
<p>{t('execution.pythonPanel.blocked')}</p>
|
||||
<p>{t('execution.pythonPanel.howToAdd')}</p>
|
||||
<p className="text-slate-500 dark:text-slate-400">{t('execution.pythonPanel.roadmap')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ interface GatewayConfigShape {
|
||||
requestTimeoutSec?: number;
|
||||
upstreamTimeoutSec?: number;
|
||||
shutdownGracefulSec?: number;
|
||||
internalTeams?: string[];
|
||||
backends?: GatewayBackend[];
|
||||
virtualKeys?: unknown[];
|
||||
}
|
||||
@@ -189,6 +190,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v);
|
||||
const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v);
|
||||
const setShutdownGraceful = (v: number | undefined) => onChange('gateway.shutdownGracefulSec', v);
|
||||
const setInternalTeams = (teams: string[]) => onChange('gateway.internalTeams', teams.length ? teams : undefined);
|
||||
|
||||
const updateBackend = (i: number, field: keyof GatewayBackend, value: unknown) => {
|
||||
const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b));
|
||||
@@ -255,6 +257,20 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<FieldLabel>{t('gateway.server.internalTeamsLabel')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={(gw.internalTeams ?? []).join(', ')}
|
||||
onChange={v =>
|
||||
setInternalTeams(
|
||||
v.split(',').map(s => s.trim()).filter(s => s.length > 0),
|
||||
)
|
||||
}
|
||||
placeholder="orchestrator, ops"
|
||||
/>
|
||||
<HelpText>{t('gateway.server.internalTeamsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<h3 className="text-sm font-medium text-slate-700">Backends</h3>
|
||||
|
||||
@@ -123,4 +123,19 @@ describe('LlmWorkersForm', () => {
|
||||
});
|
||||
expect(screen.queryByText('llmWorkers.selfLoopWarn')).toBeNull();
|
||||
});
|
||||
|
||||
it('serializes llm.maxStreamMinutes as undefined when cleared', async () => {
|
||||
const { onChange } = render({ llm: { workers: [], maxStreamMinutes: 20 } });
|
||||
const field = (screen.getAllByRole('spinbutton') as HTMLInputElement[]).find(n => n.value === '20')!;
|
||||
await userEvent.clear(field);
|
||||
expect(onChange).toHaveBeenLastCalledWith('llm.maxStreamMinutes', undefined);
|
||||
});
|
||||
|
||||
it('clears a per-worker healthcheckIntervalSeconds to undefined', async () => {
|
||||
const { onChange } = render({ llm: { workers: [{ id: 'w1', healthcheckIntervalSeconds: 30 }] } });
|
||||
const field = (screen.getAllByRole('spinbutton') as HTMLInputElement[]).find(n => n.value === '30')!;
|
||||
await userEvent.clear(field);
|
||||
const call = onChange.mock.calls.filter(([p]: [string]) => p === 'llm.workers').at(-1);
|
||||
expect(call?.[1][0].healthcheckIntervalSeconds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,9 @@ interface LlmWorker {
|
||||
maxConcurrency?: number;
|
||||
enabled?: boolean;
|
||||
vlm?: boolean;
|
||||
/** llama.cpp の prompt 評価進捗(return_progress)を要求。llama.cpp 系専用のオプトイン。 */
|
||||
returnProgress?: boolean;
|
||||
healthcheckIntervalSeconds?: number;
|
||||
/**
|
||||
* Phase 1 compat: older `provider.workers[].proxy: true` rows are
|
||||
* mapped to `connectionType: aao_gateway` by the normalizer. We
|
||||
@@ -35,6 +38,7 @@ interface LlmWorker {
|
||||
|
||||
interface LlmConfigShape {
|
||||
timeoutMinutes?: number;
|
||||
maxStreamMinutes?: number;
|
||||
retry?: {
|
||||
maxAttempts?: number;
|
||||
backoffMs?: number[];
|
||||
@@ -285,6 +289,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>{t('llmWorkers.healthcheckInterval')}</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={w.healthcheckIntervalSeconds ?? ''}
|
||||
onChange={v => updateWorker(i, { healthcheckIntervalSeconds: v === '' ? undefined : Number(v) })}
|
||||
/>
|
||||
<HelpText>{t('llmWorkers.healthcheckIntervalHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5 pt-5 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input
|
||||
@@ -307,6 +321,18 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
/>
|
||||
VLM
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
|
||||
title={t('llmWorkers.returnProgressTitle')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.returnProgress === true}
|
||||
onChange={e => updateWorker(i, { returnProgress: e.target.checked || undefined })}
|
||||
className="rounded"
|
||||
/>
|
||||
{t('llmWorkers.returnProgress')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,6 +361,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
<HelpText>{t('llmWorkers.timeoutHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Stream (minutes)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={llm.maxStreamMinutes ?? ''}
|
||||
onChange={v => onChange('llm.maxStreamMinutes', v === '' ? undefined : Number(v))}
|
||||
/>
|
||||
<HelpText>{t('llmWorkers.maxStreamHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
{t('llmWorkers.retryTitle')}
|
||||
</h3>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { SafetyForm } from './SafetyForm';
|
||||
@@ -80,4 +80,21 @@ describe('SafetyForm', () => {
|
||||
await userEvent.selectOptions(screen.getByRole('combobox'), 'always');
|
||||
expect(onChange).toHaveBeenCalledWith('safety.bashSandbox', 'always');
|
||||
});
|
||||
|
||||
it('writes historySummarization.reserveCapTokens as a Number', async () => {
|
||||
const { onChange, getConfig } = renderStateful({ safety: { historySummarization: { reserveCapTokens: 32000 } } });
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
// reserveCapTokens is the last number field (after tailTurns, preserveRecentBudget).
|
||||
const field = numbers[numbers.length - 1];
|
||||
fireEvent.change(field, { target: { value: '48000' } });
|
||||
expect(getConfig().safety.historySummarization.reserveCapTokens).toBe(48000);
|
||||
expect(onChange).toHaveBeenCalledWith('safety.historySummarization.reserveCapTokens', 48000);
|
||||
});
|
||||
|
||||
it('serializes reserveCapTokens as undefined when cleared', async () => {
|
||||
const { onChange } = render({ safety: { historySummarization: { reserveCapTokens: 32000 } } });
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
await userEvent.clear(numbers[numbers.length - 1]);
|
||||
expect(onChange).toHaveBeenLastCalledWith('safety.historySummarization.reserveCapTokens', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,13 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} />
|
||||
<HelpText>{t('safety.preserveRecentHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Reserve Cap (tokens)</FieldLabel>
|
||||
<FieldInput type="number" value={historySummarization.reserveCapTokens ?? 32000}
|
||||
onChange={v => onChange('safety.historySummarization.reserveCapTokens', v === '' ? undefined : Number(v))} />
|
||||
<HelpText>{t('safety.reserveCapHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,4 +80,11 @@ describe('ServerTlsForm', () => {
|
||||
await user.click(enableCheckbox);
|
||||
expect(onChange).toHaveBeenCalledWith('server.tls.enabled', true);
|
||||
});
|
||||
|
||||
it('changes the minimum TLS version select', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ server: { tls: {} } });
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'TLSv1.3');
|
||||
expect(onChange).toHaveBeenCalledWith('server.tls.minVersion', 'TLSv1.3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { SectionFormProps } from './types';
|
||||
* httpRedirectPort, redirectHost, selfSignedHosts.
|
||||
*
|
||||
* Fields intentionally omitted from the UI (left to config.yaml):
|
||||
* minVersion, selfSignedDir. The onChange path mechanism in ConfigFormInner
|
||||
* selfSignedDir. The onChange path mechanism in ConfigFormInner
|
||||
* uses setNestedValue which does a shallow-merge, so unedited fields are
|
||||
* preserved on save automatically.
|
||||
*
|
||||
@@ -111,6 +111,20 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) {
|
||||
<HelpText>{t('serverTls.hstsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* Minimum TLS protocol version */}
|
||||
<div>
|
||||
<FieldLabel>{t('serverTls.minVersion')}</FieldLabel>
|
||||
<select
|
||||
value={tls.minVersion ?? 'TLSv1.2'}
|
||||
onChange={e => onChange('server.tls.minVersion', 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="TLSv1.2">TLSv1.2</option>
|
||||
<option value="TLSv1.3">TLSv1.3</option>
|
||||
</select>
|
||||
<HelpText>{t('serverTls.minVersionHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* HTTP redirect port(s) — accepts a single port or a comma-separated list */}
|
||||
<div>
|
||||
<FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component test for the Settings sidebar search box.
|
||||
* i18n is not initialized, so t() returns the raw key; we assert on the literal
|
||||
* English section labels and on the onSelectSection callback.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
|
||||
describe('SettingsSidebar search', () => {
|
||||
it('filters to matching sections and jumps on click', async () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
|
||||
|
||||
// Before searching, the grouped nav shows the Safety nav button.
|
||||
expect(screen.getByTestId('settings-nav-safety')).toBeInTheDocument();
|
||||
|
||||
const box = screen.getByTestId('settings-search');
|
||||
await userEvent.type(box, 'deadline');
|
||||
|
||||
// Results list appears; Safety is a hit (deadline is one of its keywords).
|
||||
const hit = screen.getByTestId('settings-search-result-safety');
|
||||
expect(hit).toBeInTheDocument();
|
||||
// Unrelated section is filtered out.
|
||||
expect(screen.queryByTestId('settings-search-result-branding')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(hit);
|
||||
expect(onSelect).toHaveBeenCalledWith('safety');
|
||||
});
|
||||
|
||||
it('shows a no-results message for a non-matching query', async () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
|
||||
fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'zzzznotathing' } });
|
||||
expect(screen.getByTestId('settings-search-results').textContent).toContain('search.noResults');
|
||||
});
|
||||
|
||||
it('does not expose admin-only sections to a non-admin search', async () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<SettingsSidebar isAdmin={false} onSelectSection={onSelect} />);
|
||||
fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'safety' } });
|
||||
// Safety is under an adminOnly group → not searchable for a non-admin.
|
||||
expect(screen.queryByTestId('settings-search-result-safety')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
activeSection?: string;
|
||||
@@ -66,6 +68,7 @@ export const CONFIG_GROUPS = [
|
||||
{ id: 'context', label: 'Context' },
|
||||
{ id: 'safety', label: 'Safety' },
|
||||
{ id: 'reflection', label: 'Reflection' },
|
||||
{ id: 'skills-quota', label: 'Skill Quotas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,11 +137,62 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS
|
||||
|
||||
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [query, setQuery] = useState('');
|
||||
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly);
|
||||
|
||||
// Only sections the current user can actually open are searchable.
|
||||
const visibleIds = useMemo(
|
||||
() => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))),
|
||||
[visibleGroups],
|
||||
);
|
||||
const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]);
|
||||
const results = useMemo(() => searchSettings(query, index), [query, index]);
|
||||
const searching = query.trim().length > 0;
|
||||
const labelFor = (id: string, fallback: string) => {
|
||||
for (const g of visibleGroups) {
|
||||
const s = g.sections.find(x => x.id === id);
|
||||
if (s) return 'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto border-r border-hairline bg-canvas p-3">
|
||||
{visibleGroups.map(group => (
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.placeholder')}
|
||||
data-testid="settings-search"
|
||||
className="mb-3 w-full rounded-md border border-hairline bg-surface px-2 py-1.5 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
|
||||
{searching ? (
|
||||
<div data-testid="settings-search-results">
|
||||
{results.length === 0 ? (
|
||||
<div className="px-2 py-1 text-xs text-slate-400">{t('search.noResults')}</div>
|
||||
) : (
|
||||
results.map(r => {
|
||||
const active = activeSection === r.sectionId;
|
||||
return (
|
||||
<button
|
||||
key={r.sectionId}
|
||||
data-testid={`settings-search-result-${r.sectionId}`}
|
||||
onClick={() => { onSelectSection(r.sectionId); setQuery(''); }}
|
||||
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
|
||||
active ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<span>{labelFor(r.sectionId, r.label)}</span>
|
||||
<span className="ml-1 text-2xs text-slate-400">· {r.group}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
visibleGroups.map(group => (
|
||||
<div key={group.label} className="mb-3">
|
||||
<div className="section-label px-2 py-1">
|
||||
{group.label}
|
||||
@@ -154,7 +208,7 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: Set
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for SkillsQuotaForm (settings — skill store quotas).
|
||||
*
|
||||
* i18n is NOT initialized in the test env, so t() returns the raw key; we
|
||||
* assert on roles/values, not labels.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { SkillsQuotaForm } from './SkillsQuotaForm';
|
||||
|
||||
describe('SkillsQuotaForm', () => {
|
||||
it('renders all five quota fields with defaults', () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<SkillsQuotaForm config={{ skills: {} }} onChange={onChange} overriddenByEnv={{}} />);
|
||||
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
|
||||
expect(numbers).toHaveLength(5);
|
||||
// Defaults: 50, 64, 5, 100, 2000
|
||||
expect(numbers[0]).toHaveValue(50);
|
||||
expect(numbers[1]).toHaveValue(64);
|
||||
expect(numbers[2]).toHaveValue(5);
|
||||
expect(numbers[3]).toHaveValue(100);
|
||||
expect(numbers[4]).toHaveValue(2000);
|
||||
});
|
||||
|
||||
it('writes skills.maxPerUser as a Number', async () => {
|
||||
const onChange = vi.fn();
|
||||
const { getConfig } = renderStatefulForm(SkillsQuotaForm, { skills: { maxPerUser: 50 } }, { onChangeSpy: onChange });
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numbers[0], { target: { value: '12' } });
|
||||
expect(getConfig().skills.maxPerUser).toBe(12);
|
||||
expect(onChange).toHaveBeenCalledWith('skills.maxPerUser', 12);
|
||||
});
|
||||
|
||||
it('serializes a cleared quota as undefined', async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<SkillsQuotaForm config={{ skills: { maxIndexChars: 2000 } }} onChange={onChange} overriddenByEnv={{}} />);
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
await userEvent.clear(numbers[4]);
|
||||
expect(onChange).toHaveBeenLastCalledWith('skills.maxIndexChars', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Skill Quotas — per-user skill store limits (`skills.*`).
|
||||
*
|
||||
* These caps bound how many skills a user can install and how large the
|
||||
* store may grow. They exist in the config schema (SkillsConfig) but were
|
||||
* previously only editable via config.yaml. (Distinct from the SkillsForm
|
||||
* panel under User Folder, which is the skill *store* browser/editor.)
|
||||
*/
|
||||
export function SkillsQuotaForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const skills = config.skills ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('skillsQuota.title')}</h2>
|
||||
<HelpText>{t('skillsQuota.intro')}</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Per User</FieldLabel>
|
||||
<FieldInput type="number" value={skills.maxPerUser ?? 50}
|
||||
onChange={v => onChange('skills.maxPerUser', v === '' ? undefined : Number(v))} />
|
||||
<HelpText>{t('skillsQuota.maxPerUserHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Skill Size (KB)</FieldLabel>
|
||||
<FieldInput type="number" value={skills.maxSkillSizeKb ?? 64}
|
||||
onChange={v => onChange('skills.maxSkillSizeKb', v === '' ? undefined : Number(v))} />
|
||||
<HelpText>{t('skillsQuota.maxSkillSizeHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Total Size (MB)</FieldLabel>
|
||||
<FieldInput type="number" value={skills.maxTotalSizeMb ?? 5}
|
||||
onChange={v => onChange('skills.maxTotalSizeMb', v === '' ? undefined : Number(v))} />
|
||||
<HelpText>{t('skillsQuota.maxTotalSizeHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max System Skills</FieldLabel>
|
||||
<FieldInput type="number" value={skills.maxSystemSkills ?? 100}
|
||||
onChange={v => onChange('skills.maxSystemSkills', v === '' ? undefined : Number(v))} />
|
||||
<HelpText>{t('skillsQuota.maxSystemSkillsHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Index Chars</FieldLabel>
|
||||
<FieldInput type="number" value={skills.maxIndexChars ?? 2000}
|
||||
onChange={v => onChange('skills.maxIndexChars', v === '' ? undefined : Number(v))} />
|
||||
<HelpText>{t('skillsQuota.maxIndexCharsHelp')}</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ToolsMediaForm — focus on the newly surfaced
|
||||
* tools.officeMsgMaxSizeMb field.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { ToolsMediaForm } from './ToolsMediaForm';
|
||||
|
||||
describe('ToolsMediaForm', () => {
|
||||
it('shows officeMsgMaxSizeMb default (25)', () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ToolsMediaForm config={{ tools: {} }} onChange={onChange} overriddenByEnv={{}} />);
|
||||
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
|
||||
// The .msg field sits just before the Uploads field; assert the value is present.
|
||||
expect(numbers.some(n => n.value === '25')).toBe(true);
|
||||
});
|
||||
|
||||
it('writes tools.officeMsgMaxSizeMb as a Number', async () => {
|
||||
const onChange = vi.fn();
|
||||
renderStatefulForm(ToolsMediaForm, { tools: { officeMsgMaxSizeMb: 25 } }, { onChangeSpy: onChange });
|
||||
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
|
||||
const msgField = numbers.find(n => n.value === '25')!;
|
||||
await userEvent.clear(msgField);
|
||||
await userEvent.type(msgField, '40');
|
||||
expect(onChange).toHaveBeenCalledWith('tools.officeMsgMaxSizeMb', expect.any(Number));
|
||||
});
|
||||
});
|
||||
@@ -116,6 +116,12 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
|
||||
<HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>{t('tools.office.msgLabel')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.officeMsgMaxSizeMb ?? 25}
|
||||
onChange={v => onChange('tools.officeMsgMaxSizeMb', Number(v))} />
|
||||
<HelpText>{t('tools.office.msgHelp')}</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CONFIG_GROUPS } from './SettingsSidebar';
|
||||
import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
|
||||
|
||||
describe('searchSettings', () => {
|
||||
it('finds a section by a config-key keyword buried in a form', () => {
|
||||
expect(searchSettings('reserve_cap').map(r => r.sectionId)).toContain('safety');
|
||||
expect(searchSettings('deadline').map(r => r.sectionId)).toContain('safety');
|
||||
expect(searchSettings('max_stream_minutes').map(r => r.sectionId)).toContain('llm-workers');
|
||||
});
|
||||
|
||||
it('finds a section by concept in English or Japanese', () => {
|
||||
expect(searchSettings('python').map(r => r.sectionId)).toContain('execution');
|
||||
expect(searchSettings('tls').map(r => r.sectionId)).toContain('server-tls');
|
||||
expect(searchSettings('証明書').map(r => r.sectionId)).toContain('server-tls');
|
||||
expect(searchSettings('デッドライン').map(r => r.sectionId)).toContain('safety');
|
||||
});
|
||||
|
||||
it('is AND across whitespace-separated terms', () => {
|
||||
const r = searchSettings('browser timeout');
|
||||
expect(r.map(x => x.sectionId)).toContain('tools-browser');
|
||||
// "browser timeout" should not match, say, safety (has neither pair).
|
||||
expect(r.every(x => `${x.label} ${x.keywords}`.toLowerCase().includes('browser'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-insensitive and returns nothing for an empty query', () => {
|
||||
expect(searchSettings('SAFETY').map(r => r.sectionId)).toContain('safety');
|
||||
expect(searchSettings(' ')).toEqual([]);
|
||||
expect(searchSettings('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('can be scoped to a caller-supplied (e.g. non-admin) index subset', () => {
|
||||
const onlyPrefs = buildSettingsSearchIndex().filter(e => e.sectionId === 'preferences');
|
||||
// "safety" is admin-only → absent from a preferences-only index.
|
||||
expect(searchSettings('safety', onlyPrefs)).toEqual([]);
|
||||
expect(searchSettings('preferences', onlyPrefs).map(r => r.sectionId)).toEqual(['preferences']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index integrity (drift guard)', () => {
|
||||
const allIds = CONFIG_GROUPS.flatMap(g => g.sections.map(s => s.id));
|
||||
|
||||
it('covers every sidebar section (no section left unsearchable)', () => {
|
||||
const index = buildSettingsSearchIndex();
|
||||
const indexedIds = new Set(index.map(e => e.sectionId));
|
||||
for (const id of allIds) expect(indexedIds.has(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('every indexed section has non-empty keywords', () => {
|
||||
for (const e of buildSettingsSearchIndex()) {
|
||||
expect(e.keywords.trim().length, `section ${e.sectionId} has no keywords`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* settingsSearchIndex.ts — a lightweight static manifest for searching the
|
||||
* admin Settings screen.
|
||||
*
|
||||
* The Settings sidebar has ~25 sections spread across 8 groups; the specific
|
||||
* knob you want (e.g. "deadline", "reserve_cap_tokens", "min TLS version") is
|
||||
* often buried in a form whose section name does not mention it. This manifest
|
||||
* maps hand-authored keywords → sectionId so a search box can jump straight to
|
||||
* the right section.
|
||||
*
|
||||
* Deliberately NOT auto-generated from the forms: keeping it a small explicit
|
||||
* list is lower-risk (no form refactor) and lets us add synonyms / Japanese
|
||||
* terms. A test asserts every entry points at a real section id and every
|
||||
* admin section is covered, so the manifest cannot silently drift.
|
||||
*/
|
||||
|
||||
import { CONFIG_GROUPS } from './SettingsSidebar';
|
||||
|
||||
export interface SettingsSearchEntry {
|
||||
sectionId: string;
|
||||
/** English display label (from the sidebar). */
|
||||
label: string;
|
||||
/** Group the section lives under (e.g. "Agent Runtime"). */
|
||||
group: string;
|
||||
/** Free-text keywords: config keys, concepts, and Japanese synonyms. */
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
/** sectionId → extra searchable keywords (config keys, concepts, JP terms). */
|
||||
const KEYWORDS: Record<string, string> = {
|
||||
// Preference
|
||||
preferences: 'preferences default visibility 公開範囲 個人設定 デフォルト',
|
||||
notifications: 'notifications browser web push 通知 ブラウザ通知 購読',
|
||||
pets: 'pets mascot chat backend worker ペット マスコット',
|
||||
'memory-learning': 'reflection history memory learning revert 学習 メモリ 履歴 リフレクション',
|
||||
'a2a-delegations': 'a2a delegation token revoke 委任 取り消し 外部エージェント',
|
||||
// System
|
||||
branding: 'branding app name logo favicon accent color テーマ ロゴ アプリ名 色',
|
||||
'paths-storage': 'storage paths worktree upload dir data directory 保存先 ディレクトリ アップロード上限 worktree_dir',
|
||||
execution: 'execution concurrency max_movements retry backoff python packages 並列度 リトライ 実行 サンドボックス python',
|
||||
auth: 'authentication login google gitea oauth local password provider 認証 ログイン',
|
||||
organizations: 'organizations org members visibility 組織 メンバー',
|
||||
'push-notifications': 'web push vapid server 配信 サーバー鍵',
|
||||
// LLM
|
||||
'llm-workers': 'llm workers model endpoint api_key concurrency timeout max_stream_minutes healthcheck ワーカー モデル 接続 タイムアウト',
|
||||
'gateway-server': 'gateway openai compatible virtual keys internal_teams listen port ゲートウェイ 仮想キー',
|
||||
'llm-metrics': 'metrics prometheus exporter gateway 計測 メトリクス',
|
||||
// Agent Runtime
|
||||
'ask-subtasks': 'ask limit subtasks spawn_stagger_ms max_per_parent サブタスク 質問上限 間引き',
|
||||
context: 'context threshold warn prompt force_transition token limit コンテキスト 閾値 使用率',
|
||||
safety: 'safety max_iterations max_revisits deadline max_job_minutes grace bash sandbox network reserve_cap_tokens history summarization 自爆防止 デッドライン 反復 サンドボックス ネットワーク',
|
||||
reflection: 'reflection learning cooldown budget auto apply 自動学習 クールダウン',
|
||||
'skills-quota': 'skills quota max per user size limit maxPerUser maxSkillSizeKb maxTotalSizeMb maxSystemSkills maxIndexChars スキル クォータ 上限 個数 サイズ',
|
||||
// Tools
|
||||
'tools-web': 'web search websearch webfetch search_filter 検索 ウェブ',
|
||||
'tools-browser': 'browser playwright browseweb timeout channel ブラウザ タイムアウト',
|
||||
'tools-media': 'media vision ocr audio office excel docx pdf pptx msg size 画像 音声 文書 ファイル上限',
|
||||
'tools-external': 'external api keys x twitter maps amazon youtube 外部サービス キー',
|
||||
'search-filter': 'search filter domain allow deny websearch ドメイン 許可 除外',
|
||||
// MCP & Connections
|
||||
mcp: 'mcp model context protocol runtime quota サーバー クォータ',
|
||||
// SSH
|
||||
ssh: 'ssh remote connection grant audit master key 接続 監査 鍵ローテーション',
|
||||
// Network
|
||||
'server-tls': 'https tls certificate self signed hsts redirect min_version 証明書 リダイレクト 暗号化',
|
||||
};
|
||||
|
||||
/** Build the flat search index from the sidebar's canonical section list. */
|
||||
export function buildSettingsSearchIndex(): SettingsSearchEntry[] {
|
||||
const entries: SettingsSearchEntry[] = [];
|
||||
for (const group of CONFIG_GROUPS) {
|
||||
for (const s of group.sections) {
|
||||
entries.push({
|
||||
sectionId: s.id,
|
||||
label: s.label,
|
||||
group: group.label,
|
||||
keywords: KEYWORDS[s.id] ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive AND search: every whitespace-separated term must appear in
|
||||
* the section's label, group, id, or keywords. Empty query → no results (the
|
||||
* caller shows the normal grouped nav instead).
|
||||
*/
|
||||
export function searchSettings(
|
||||
query: string,
|
||||
index: SettingsSearchEntry[] = buildSettingsSearchIndex(),
|
||||
): SettingsSearchEntry[] {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length === 0) return [];
|
||||
return index.filter((e) => {
|
||||
const haystack = `${e.label} ${e.group} ${e.sectionId} ${e.keywords}`.toLowerCase();
|
||||
return terms.every((t) => haystack.includes(t));
|
||||
});
|
||||
}
|
||||
@@ -12,14 +12,26 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
|
||||
|
||||
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
|
||||
|
||||
## 2026-07-09 — 設定画面に「スキルのクォータ」を追加し、これまで隠れていた設定を編集可能に
|
||||
|
||||
これまで `config.yaml` を直接いじらないと変えられなかった項目を、設定画面から編集できるようにしました。Agent Runtime に新しく「スキルのクォータ」セクションが増え、1 ユーザーあたりのスキル数やサイズ上限を調整できます。あわせて、LLM の暴走を止める総時間の上限(Max Stream)、サブタスク発火の間引き遅延、履歴要約の headroom 上限、Outlook メール(.msg)の読み込みサイズ上限、TLS の最小バージョン、Gateway の内部チーム、ワーカーのヘルスチェック間隔も、それぞれの設定セクションから変更できます。さらに Execution 画面に「Python サンドボックスのパッケージ」案内を追加し、どのパッケージが使えるか・どう増やすかを確認できるようにしました(→[設定](./17-settings.md))。
|
||||
|
||||
## 2026-07-08 — 名前が食い違ったスキルを削除・編集できない問題を修正
|
||||
|
||||
スキルの表示名(SKILL.md の frontmatter `name:`)と保存フォルダ名が食い違っていると、一覧には表示されるのに削除・編集だけが「Skill not found」で失敗し続ける問題を修正しました。表示名から実体を探すようになったため、既存の食い違いスキルもそのまま削除・編集できます。あわせて、InstallSkill やスキル作成時に frontmatter の `name:` と指定名の一致を必須にして、新たな食い違いが生まれないようにしました。削除・保存に失敗したときのエラーはボタンのすぐ近くに表示されます。
|
||||
|
||||
## 2026-07-08 — 設定画面にセクション検索を追加
|
||||
|
||||
システム設定(admin)の左サイドバー上部に検索ボックスを追加しました。探している設定のキーワードを入れると、該当セクションだけに絞り込めます。項目名だけでなく、`config.yaml` のキー名や概念でも引けます(例:「デッドライン」「reserve_cap」→ Safety、「python」→ Execution、「TLS」「証明書」→ HTTPS / TLS)。複数語のスペース区切りは AND 検索、クリックでそのセクションが開きます。表示されるのは自分が開ける権限のあるセクションだけです(→[システム設定](./17-settings.md))。
|
||||
|
||||
## 2026-07-08 — タスクが開始直後に「200回超過」で突然中断される問題を修正
|
||||
|
||||
会話がコンテキスト上限のごく近くまで膨らんだとき、まれにエージェントが「同じリクエストの送信 → 送信前ブロック」を1回あたり十数ミリ秒で延々と繰り返し、実際には何も作業しないまま数分で「max iterations (200) 超過」として中断されることがありました。空回りはツールを使わないため会話タブには何も表示されず、タスクが始まった直後に突然打ち切られたように見えていました。原因はコンテキストサイズの見積もりが送信前チェックとガードで 128 トークンだけずれていたことで、見積もりを統一し、さらに「何も削れないのに再送する」経路自体を塞ぎました。今後この状況では空回りせず、コンテキスト超過として即座に次のステップへの強制遷移(または中断)になります。あわせて、これまで無制限だった Grep の結果出力にも Read や Bash と同じ自動切り詰めを入れ、巨大な検索結果が一撃で会話を埋める事故を防ぎます。
|
||||
|
||||
## 2026-07-08 — 「いま何を待っているか」が見えるようになりました(LLM 実行の可視化)
|
||||
|
||||
エージェントが LLM の応答を待っている間、チャットが無言になる問題を解消しました。チャット下部のスピナーに「LLM の応答を待っています…」「思考中…(〇〇文字)」「LLM リトライ待ち 2/3(HTTP 500)」「コンテキストを整理中…」など、いま実際に起きていることが表示されます。あわせて、①中断メッセージの 1 行目に失敗の分類(context_overflow / gateway_timeout など)と発生ステップが明記されるようになり、②自動再試行中はヘッダーに「試行 2/3」バッジが出るようになり、③トレースタブの LLM 行に所要時間・リトライ回数・思考量・担当バックエンド・エラー分類が記録されるようになりました。llama.cpp(llama-server)利用時は、設定 → LLM Workers の「プロンプト処理進捗」を有効にすると長いプロンプト処理中に進捗バーも出ます。詳しくは[実行中のタスクを見る・介入する](03-running.md)と[トラブルシューティング](08-troubleshooting.md)へ。
|
||||
|
||||
## 2026-07-08 — 会話画面を縦に広く、入力欄をカード型に刷新
|
||||
|
||||
会話画面の縦方向の使える範囲を広げました。ヘッダー部分の行数を減らすため、公開範囲の表示(ワークスペース内のチャットは常にメンバーに公開されるという前提)は撤去し、継続・共有・削除ボタンはタブ行の右端にまとめています。入力欄はカード型のコンポーザーに刷新し、書いた内容に応じて高さが自動で伸びます(1〜8 行程度)。コンテキストの残量ゲージはツールバー右下に常時表示し、使用率が 70% を超えるとパーセント表示も出るようになりました。
|
||||
@@ -118,7 +130,6 @@ A2A 連携を通じて接続する外部エージェントに、委任ごとの
|
||||
## 2026-07-03 — Shift_JIS などの日本語テキストが「バイナリ」と誤判定されて読めない不具合を修正
|
||||
|
||||
Windows で保存した日本語の `.txt` や、Excel から出した Shift_JIS(CP932)の CSV を Read で開こうとすると、中身は読めるテキストなのに「バイナリなので開けません」と拒否されていました。文字コードの判定が UTF-8 しか想定しておらず、UTF-8 として解釈できないものをすべてバイナリと見なしていたためです。Shift_JIS・EUC-JP などを自動で判別し、UTF-8 に変換して読めるようにしました。Edit で書き換えた場合は元の文字コードのまま保存します。あわせて Grep が画像などのバイナリファイルを拾ってしまい、検索結果にバイナリの断片が紛れ込む問題も直しました(対象フォルダにバイナリがあっても自動でスキップします)(→[ツール](./16-tools.md))。
|
||||
|
||||
## 2026-07-03 — ワークスペースに登録した SSH 接続でコンソールが開けない不具合を修正
|
||||
|
||||
ワークスペースに登録した SSH 接続に対して**コンソール(対話シェル)を開こうとすると、接続が正しく登録されているのに `access denied (space_mismatch)` で弾かれる**不具合を修正しました。原因はコンソールを開く経路が、そのタスクの所属ワークスペース(スペース)を権限判定に渡していなかったことです。一発実行の `SshExec` は影響を受けていませんでしたが、コンソール経路(エージェントの `SshConsoleEnsure`、および「コンソール」タブから手動で開く操作・再接続)はすべて対象でした。個人ワークスペースのタスクでも正しく開けるようにしています。あわせて、公開・組織共有などで**タスクは見えてもそのワークスペースのメンバーではない人**が、コンソールからそのワークスペースの SSH 接続を使えてしまわないよう、実行時にメンバーであることを確認するようにしました(→[SSH 接続](./14-ssh.md))。
|
||||
|
||||
@@ -20,6 +20,19 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
|
||||
|
||||
実行中はヘッダーに `running`(サブタスク待ちのときは `subtasks`)のバッジが点滅します。
|
||||
|
||||
## 「いま何を待っているか」のライブ表示
|
||||
|
||||
画面に何も流れていない時間も、エージェントが止まっているとは限りません。LLM の応答待ちの間、チャット下部のスピナーに現在の状態が具体的に表示されます。
|
||||
|
||||
- **LLM の応答を待っています…**: リクエストを送信して最初のトークンを待っている状態
|
||||
- **思考中…(〇〇文字)**: thinking 対応モデルが内部推論を出力中。文字数が増えていれば正常に動いています
|
||||
- **LLM リトライ待ち 2/3(HTTP 500)**: バックエンドが一時エラーを返し、自動リトライの待機中
|
||||
- **コンテキストを整理中…**: 会話履歴が上限に近づき、圧縮や要約を実行中
|
||||
|
||||
同じ内容はタスク一覧の「現在の作業」欄にも反映されます。中断後に自動再試行が走っている場合は、ヘッダーに **試行 2/3** のバッジが出ます(ホバーで前回の中断理由を表示)。
|
||||
|
||||
また llama.cpp(llama-server)のバックエンドでは、設定 → LLM Workers の「プロンプト処理進捗」を有効にすると、長いプロンプトの処理中に進捗バー(`Processing 〇〇%`)が出るようになります。
|
||||
|
||||
## 進捗タブで振り返る
|
||||
|
||||
詳細パネル上部のタブ(概要 / 進捗 / ファイル / トレース、ブラウザ・SSH は接続中のみ)のうち、**進捗** タブに時系列のビューがあります。各ステップで何のツールをどんな引数で呼び、何を生成したかを上から下へ追え、その中の **タイムライン** で movement ごとにまとまった流れも確認できます(別タブではなく、進捗タブ内のセクションです)。
|
||||
|
||||
@@ -26,7 +26,7 @@ keywords: [トラブル, エラー, 失敗, waiting, スタック, 再試行]
|
||||
|
||||
## タスクが失敗した(failed)/ 再試行について
|
||||
|
||||
タスクは失敗しても、設定された最大試行回数まで自動で再キューされて再実行されます。再実行時には前回の失敗内容が引き継ぎコンテキストとして渡され、同じ作業の重複を避けようとします。
|
||||
タスクは失敗しても、設定された最大試行回数まで自動で再キューされて再実行されます。再実行時には前回の失敗内容が引き継ぎコンテキストとして渡され、同じ作業の重複を避けようとします。再試行中はチャットのヘッダーに **試行 2/3** のバッジが表示され、ホバーすると前回の中断理由が確認できます。
|
||||
|
||||
**対処**:
|
||||
|
||||
@@ -34,6 +34,17 @@ keywords: [トラブル, エラー, 失敗, waiting, スタック, 再試行]
|
||||
- 入力ファイルや依頼文に問題があれば修正して新しいタスクを作る
|
||||
- 最大試行回数を使い切っても失敗し続ける場合は、依頼を分割するか、より能力の高いモデルでの実行を管理者に相談する
|
||||
|
||||
## 「⚠️ 中断」メッセージの読み方
|
||||
|
||||
LLM 起因で中断したタスクの中断メッセージには、1 行目に失敗の分類が入ります。
|
||||
|
||||
- **分類: context_overflow** — 会話がモデルのコンテキスト上限を超えた。依頼を分割するか、大きなファイルの全文読み込みを避ける指示を足す
|
||||
- **分類: idle_timeout / hard_cap** — LLM バックエンドが規定時間内に応答を返さなかった(idle_timeout)か、1 回の応答が長すぎて上限で打ち切られた(hard_cap)。バックエンドの負荷や暴走生成のサイン
|
||||
- **分類: gateway_timeout / gateway_shutdown** — LLM ゲートウェイ(配車側)がバックエンドとの通信を打ち切った。特定バックエンドの不調が疑われる
|
||||
- **分類: http (HTTP 5xx) / connection** — バックエンドがエラーを返した、または接続できなかった
|
||||
|
||||
どの LLM 呼び出しで何が起きたかの詳細は、詳細パネルの **トレース** タブで確認できます。各呼び出しの所要時間・トークン数・リトライ回数・担当バックエンド・エラー分類が 1 行ずつ記録されています。
|
||||
|
||||
## エージェントが「ログインを要求された」と返す
|
||||
|
||||
タスク中のブラウザ操作(BrowseWeb)で、保存済みのログイン情報が期限切れになっているとこのメッセージが返ります。
|
||||
|
||||
@@ -18,6 +18,10 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は *
|
||||
- **右フォーム** — 選択したセクションの設定項目。ほとんどはその場で編集するインラインフォーム
|
||||
- `User` グループ以外は **admin 専用**。一般ユーザーには表示されない (`adminOnly`)
|
||||
|
||||
## 設定を検索する
|
||||
|
||||
サイドバー上部の検索ボックスに、探している設定のキーワードを入れると、該当するセクションだけが一覧に絞り込まれます。項目名だけでなく、その設定の中身(`config.yaml` のキー名や概念)でも引けます。たとえば「デッドライン」「reserve_cap」で **Safety**、「python」で **Execution**、「TLS」「証明書」で **HTTPS / TLS** に飛べます。複数の語をスペースで区切ると、そのすべてを含むセクションに絞り込みます(AND 検索)。結果をクリックするとそのセクションが開きます。表示されるのは、あなたが開ける権限のあるセクションだけです。
|
||||
|
||||
## セクション一覧
|
||||
|
||||
サイドバーのグループとセクションは以下の通りです。
|
||||
@@ -41,7 +45,7 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は *
|
||||
|-----------|------|
|
||||
| Branding | アプリ名・ロゴ・アクセント色などの見た目 |
|
||||
| Paths & Storage | `storage.*` の作業ディレクトリ・ワークスペース保存先・アップロード上限 |
|
||||
| Execution | `concurrency` (全 worker 合計の並列度)・`max_movements`・`retry.*` |
|
||||
| Execution | `concurrency` (全 worker 合計の並列度)・`max_movements`・`retry.*`。末尾に **Python サンドボックスで使えるパッケージ**の案内(読み取り専用)あり |
|
||||
| Authentication | ログイン方式(Google / Gitea OAuth・ローカル認証)の有効化と設定 |
|
||||
| 🏢 Organizations | ローカル組織の作成・メンバー管理(`org` 公開範囲の単位) |
|
||||
| Web Push (Server) | Web Push 配信のサーバー側設定(VAPID キーなど) |
|
||||
@@ -60,10 +64,11 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。
|
||||
|
||||
| セクション | 内容 |
|
||||
|-----------|------|
|
||||
| Ask / Subtasks | ASK 上限・サブタスクの制御 |
|
||||
| Ask / Subtasks | ASK 上限・サブタスクの制御(サブタスク発火の間引き `spawn_stagger_ms` を含む) |
|
||||
| Context | コンテキスト使用率の警告閾値 (warn / prompt / force_transition) |
|
||||
| Safety | `max_iterations`・`max_revisits`・**実行デッドライン(Max Job Minutes / Deadline Grace)**・history 要約などの自爆防止 |
|
||||
| Safety | `max_iterations`・`max_revisits`・**実行デッドライン(Max Job Minutes / Deadline Grace)**・history 要約(headroom 上限 `reserve_cap_tokens` を含む)などの自爆防止 |
|
||||
| Reflection | タスク完了後の自動学習。詳細は [Reflection の調整](#reflection) |
|
||||
| スキルのクォータ | 1 ユーザーあたりのスキル数・スキルサイズ・合計サイズなどの上限(`skills.*`) |
|
||||
|
||||
### Tools グループ (admin)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { reduceDelegateStreams } from './useJobStream';
|
||||
import { reduceDelegateStreams, reduceLlmState } from './useJobStream';
|
||||
|
||||
describe('reduceDelegateStreams – originJobId contract', () => {
|
||||
it('lifecycle の originJobId を run に紐づける', () => {
|
||||
@@ -65,3 +65,34 @@ describe('reduceDelegateStreams – originJobId contract', () => {
|
||||
expect(s['r3'].originJobId).toBe('sub2');
|
||||
});
|
||||
});
|
||||
|
||||
// 観測性 Phase 1: SSE llm_state → LlmStateEntry 変換。
|
||||
describe('reduceLlmState', () => {
|
||||
it('maps a waiting event with movement/iteration metadata', () => {
|
||||
const entry = reduceLlmState({ type: 'llm_state', phase: 'waiting', movement: 'dig' }, 1000);
|
||||
expect(entry).toMatchObject({ phase: 'waiting', movement: 'dig', receivedAt: 1000 });
|
||||
});
|
||||
|
||||
it('maps retrying with attempt info', () => {
|
||||
const entry = reduceLlmState(
|
||||
{ type: 'llm_state', phase: 'retrying', attempt: 2, maxAttempts: 3, reason: 'HTTP 500', errorClass: 'http' },
|
||||
2000,
|
||||
);
|
||||
expect(entry).toMatchObject({ phase: 'retrying', attempt: 2, maxAttempts: 3, reason: 'HTTP 500' });
|
||||
});
|
||||
|
||||
it('maps thinking with cumulative chars', () => {
|
||||
const entry = reduceLlmState({ type: 'llm_state', phase: 'thinking', chars: 4200 }, 0);
|
||||
expect(entry).toMatchObject({ phase: 'thinking', chars: 4200 });
|
||||
});
|
||||
|
||||
it('maps recovering with the stage name', () => {
|
||||
const entry = reduceLlmState({ type: 'llm_state', phase: 'recovering', stage: 'force_transition_summary' }, 0);
|
||||
expect(entry).toMatchObject({ phase: 'recovering', stage: 'force_transition_summary' });
|
||||
});
|
||||
|
||||
it('returns null for unknown phases and non-llm_state events', () => {
|
||||
expect(reduceLlmState({ type: 'llm_state', phase: 'bogus' }, 0)).toBeNull();
|
||||
expect(reduceLlmState({ type: 'text_delta' }, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,12 +25,31 @@ export interface DelegateStreamEntry {
|
||||
originJobId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 呼び出しのライブ状態(SSE llm_state イベント由来・揮発)。
|
||||
* 「20分無応答」の間に実際は何をしているか(応答待ち/思考中/リトライ/
|
||||
* コンテキスト復旧)をチャットのステータス行に出すためのもの。
|
||||
*/
|
||||
export interface LlmStateEntry {
|
||||
phase: 'waiting' | 'thinking' | 'retrying' | 'recovering';
|
||||
movement?: string;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
reason?: string;
|
||||
errorClass?: string;
|
||||
chars?: number;
|
||||
stage?: string;
|
||||
/** クライアント側で受信した時刻 (ms)。経過時間表示用。 */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
export interface JobStreamState {
|
||||
promptProgress: PromptProgressState | null;
|
||||
streamingText: string;
|
||||
toolCallStream: Record<string, ToolCallStreamEntry>;
|
||||
connected: boolean;
|
||||
delegateStreams: Record<string, DelegateStreamEntry>;
|
||||
llmState: LlmStateEntry | null;
|
||||
}
|
||||
|
||||
function emptyEntry(delegateRunId: string): DelegateStreamEntry {
|
||||
@@ -40,6 +59,29 @@ function emptyEntry(delegateRunId: string): DelegateStreamEntry {
|
||||
};
|
||||
}
|
||||
|
||||
/** SSE llm_state イベントを LlmStateEntry へ変換する純関数。不正 phase は null。 */
|
||||
export function reduceLlmState(
|
||||
data: { type: string; phase?: string; movement?: string; attempt?: number;
|
||||
maxAttempts?: number; reason?: string; errorClass?: string; chars?: number; stage?: string },
|
||||
now: number,
|
||||
): LlmStateEntry | null {
|
||||
if (data.type !== 'llm_state') return null;
|
||||
if (data.phase !== 'waiting' && data.phase !== 'thinking' && data.phase !== 'retrying' && data.phase !== 'recovering') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
phase: data.phase,
|
||||
movement: data.movement,
|
||||
attempt: data.attempt,
|
||||
maxAttempts: data.maxAttempts,
|
||||
reason: data.reason,
|
||||
errorClass: data.errorClass,
|
||||
chars: data.chars,
|
||||
stage: data.stage,
|
||||
receivedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** SSE 1 イベントを delegateStreams マップへ畳み込む純関数。 */
|
||||
export function reduceDelegateStreams(
|
||||
prev: Record<string, DelegateStreamEntry>,
|
||||
@@ -75,6 +117,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
|
||||
const [toolCallStream, setToolCallStream] = useState<Record<string, ToolCallStreamEntry>>({});
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [delegateStreams, setDelegateStreams] = useState<Record<string, DelegateStreamEntry>>({});
|
||||
const [llmState, setLlmState] = useState<LlmStateEntry | null>(null);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
const isActive = jobStatus === 'running' || jobStatus === 'dispatching';
|
||||
@@ -94,6 +137,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
|
||||
setStreamingText('');
|
||||
setToolCallStream({});
|
||||
setDelegateStreams({});
|
||||
setLlmState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,12 +159,21 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
|
||||
timeMs: data.timeMs ?? 0,
|
||||
});
|
||||
break;
|
||||
case 'llm_state':
|
||||
// 直前の prompt_progress を残すと、ChatPane の表示優先順位
|
||||
// (promptProgress > llmState) で新しいライブ状態が古い進捗バーに
|
||||
// 隠れる (Codex P2)。状態遷移が届いた時点で進捗は陳腐化している。
|
||||
setPromptProgress(null);
|
||||
setLlmState(reduceLlmState(data, Date.now()));
|
||||
break;
|
||||
case 'text_delta':
|
||||
setPromptProgress(null);
|
||||
setLlmState(null);
|
||||
setStreamingText(prev => prev + (data.text ?? ''));
|
||||
break;
|
||||
case 'tool_use_delta':
|
||||
setPromptProgress(null);
|
||||
setLlmState(null);
|
||||
setToolCallStream(prev => {
|
||||
const callId = data.callId ?? '';
|
||||
const existing = prev[callId];
|
||||
@@ -138,6 +191,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
|
||||
case 'tool_use':
|
||||
setStreamingText('');
|
||||
setPromptProgress(null);
|
||||
setLlmState(null);
|
||||
setToolCallStream(prev => {
|
||||
if (!data.callId || !(data.callId in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
@@ -155,6 +209,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
|
||||
setPromptProgress(null);
|
||||
setToolCallStream({});
|
||||
setDelegateStreams({});
|
||||
setLlmState(null);
|
||||
cleanup();
|
||||
break;
|
||||
}
|
||||
@@ -169,5 +224,5 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
|
||||
return cleanup;
|
||||
}, [taskId, isActive, cleanup]);
|
||||
|
||||
return { promptProgress, streamingText, toolCallStream, connected, delegateStreams };
|
||||
return { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState };
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"generating": "{{name}} generating…",
|
||||
"processing": "Processing",
|
||||
"agentResponding": "Agent is generating a response...",
|
||||
"llmWaiting": "Waiting for the LLM to respond…",
|
||||
"llmThinking": "Thinking… ({{chars}} chars)",
|
||||
"llmRetrying": "LLM retry {{attempt}}/{{max}} ({{reason}})",
|
||||
"llmRecovering": "Compacting context… ({{stage}})",
|
||||
"attemptBadge": "Attempt {{attempt}}/{{max}}",
|
||||
"attemptBadgeTitle": "Previous abort reason: {{reason}}",
|
||||
"sendFailed": "Failed to send",
|
||||
"interjectHint": "Agent running — send a message to give it instructions",
|
||||
"agentRunningWait": "The agent is running your task. Please wait a moment.",
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
"pptxLabel": "ReadPPTX max size",
|
||||
"pptxHelp": "Maximum .pptx file size accepted by ReadPPTX (default: 50 MB)",
|
||||
"pptxUncompressedLabel": "ReadPPTX uncompressed size limit",
|
||||
"pptxUncompressedHelp": "Total size limit after unzipping a PPTX (for ZIP-bomb detection, default: 200 MB)"
|
||||
"pptxUncompressedHelp": "Total size limit after unzipping a PPTX (for ZIP-bomb detection, default: 200 MB)",
|
||||
"msgLabel": "ReadMsg max size",
|
||||
"msgHelp": "Maximum .msg (Outlook email) file size accepted by ReadMsg (default: 25 MB)"
|
||||
},
|
||||
"uploads": {
|
||||
"sectionHelp": "Request body limit for the upload API from the UI (MB)",
|
||||
@@ -254,6 +256,8 @@
|
||||
"enable": "Enable Gateway",
|
||||
"listenPortHelp": "Not used in same-process mode: it shares the same port as the worker UI ({{port}}). Only effective when started as a separate process with AAO_MODE=gateway.",
|
||||
"separateDeploy": "Separate-process deploy:",
|
||||
"internalTeamsLabel": "Internal teams",
|
||||
"internalTeamsHelp": "Comma-separated team names treated as internal: their traffic is exempt from usage metering/billing. This server-side list is authoritative (never a client-supplied header).",
|
||||
"backendsHelp1": "Routing targets such as llama-server / Ollama / vLLM. The Gateway routes to the least-busy backend among those serving the role the worker sends (a backend with no roles set serves all roles). It falls back to an exact request.model = id/model match only when no backend serves the role.",
|
||||
"backendsHelp2": "api_key storage format: the value entered in the form is saved to config.yaml in plain text. A ${VAR}-style env-var reference is saved as a literal string on form save, so edit config.yaml directly if you want to pass it via env.",
|
||||
"backendsEmpty": "No backends registered. Add at least one.",
|
||||
@@ -524,11 +528,16 @@
|
||||
"apiKeyDirectHelp": "Set only when Bearer auth is required. Leave empty for standalone Ollama.",
|
||||
"modelHelp": "If the endpoint serves /models, candidates appear in the dropdown. If not (auth required, behind a proxy, etc.), type it in directly.",
|
||||
"maxConcurrency": "Max concurrency",
|
||||
"healthcheckInterval": "Healthcheck interval (sec)",
|
||||
"healthcheckIntervalHelp": "How often to health-check this worker (seconds). Leave blank for the default.",
|
||||
"enabled": "Enabled",
|
||||
"vlmTitle": "When the model supports VLM, ReadImage uses the worker's own model",
|
||||
"returnProgress": "Prompt progress",
|
||||
"returnProgressTitle": "llama.cpp (llama-server) only: streams prompt-eval progress so the chat shows \"processing prompt n%\". Leave off for other backends — they may reject the extra request field",
|
||||
"addWorker": "+ Add worker",
|
||||
"globalTitle": "Global LLM Settings",
|
||||
"timeoutHelp": "Timeout for an LLM request (minutes). Default: 10",
|
||||
"maxStreamHelp": "Hard wall-clock ceiling for a single LLM call including retries (minutes). Unlike Timeout it never resets on new chunks, so a runaway generation is still aborted. Blank = 2× Timeout; 0 disables (not recommended).",
|
||||
"retryTitle": "Retry (per-call HTTP)",
|
||||
"maxAttemptsHelp": "Maximum attempts for a single LLM API call",
|
||||
"backoffHelp": "Wait time between retries (ms). Consumed in array order.",
|
||||
@@ -581,7 +590,8 @@
|
||||
"historyEnable": "Enable automatic history summarization",
|
||||
"historyEnableHelp": "Automatically summarize old conversation history to save context. Default: enabled",
|
||||
"tailTurnsHelp": "Number of recent assistant+tool turns to always keep. Default: 2",
|
||||
"preserveRecentHelp": "Token budget of recent messages preserved without summarization. Default: 8000"
|
||||
"preserveRecentHelp": "Token budget of recent messages preserved without summarization. Default: 8000",
|
||||
"reserveCapHelp": "Upper bound on the headroom reserved above the prompt before compact-and-continue fires. Lets large-context models actually use their headroom. Default: 32000"
|
||||
},
|
||||
"mcp": {
|
||||
"intro": "Settings for connecting to and running external MCP (Model Context Protocol) servers. To add a target server, specify its MCP server URL from a task or from settings.",
|
||||
@@ -677,7 +687,15 @@
|
||||
"concurrencyHelp": "Number of jobs that can run concurrently",
|
||||
"maxMovementsHelp": "Maximum number of movements per job",
|
||||
"maxAttemptsHelp": "Maximum retry attempts on job failure. Default: 3",
|
||||
"backoffHelp": "Retry interval (seconds). Comma-separated. Default: 60, 300, 900"
|
||||
"backoffHelp": "Retry interval (seconds). Comma-separated. Default: 60, 300, 900",
|
||||
"pythonPanel": {
|
||||
"title": "Python sandbox packages",
|
||||
"intro": "Agent python runs inside the Bash sandbox. Only pre-baked packages can be imported.",
|
||||
"preinstalled": "Pre-installed examples: pandas, numpy, openpyxl, python-docx, pymupdf. The authoritative list is runtime/python-requirements.txt.",
|
||||
"blocked": "pip install inside the sandbox is currently blocked (the sandbox has its network cut off).",
|
||||
"howToAdd": "To add a package, an admin edits runtime/python-requirements.txt and re-provisions the host (scripts/prebake-python.sh).",
|
||||
"roadmap": "Using pip directly inside the sandbox is planned for a future release."
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"limitPlaceholder": "auto (fetched from the Ollama API)",
|
||||
@@ -739,7 +757,17 @@
|
||||
"askSubtasks": {
|
||||
"askMaxHelp": "Limit of ASKs (questions to the user) per job",
|
||||
"maxDepthHelp": "Maximum nesting depth for subtasks",
|
||||
"maxPerParentHelp": "Maximum number of subtasks a single parent job can spawn. Default: 10"
|
||||
"maxPerParentHelp": "Maximum number of subtasks a single parent job can spawn. Default: 10",
|
||||
"spawnStaggerHelp": "Delay between consecutive SpawnSubTask enqueues (ms), so a burst doesn't monopolize GPU priority. 0 disables. Default: 1000"
|
||||
},
|
||||
"skillsQuota": {
|
||||
"title": "Skill Quotas",
|
||||
"intro": "Per-user limits on the skill store: how many skills a user may install and how large the store can grow.",
|
||||
"maxPerUserHelp": "Maximum number of skills per user. Default: 50",
|
||||
"maxSkillSizeHelp": "Maximum size of a single skill (KB). Default: 64",
|
||||
"maxTotalSizeHelp": "Maximum total size of all a user's skills (MB). Default: 5",
|
||||
"maxSystemSkillsHelp": "Maximum number of system skills. Default: 100",
|
||||
"maxIndexCharsHelp": "Maximum characters in the skill index. Default: 2000"
|
||||
},
|
||||
"namespaceEditor": {
|
||||
"addAriaDisabled": "Adding new entries is disabled",
|
||||
@@ -776,6 +804,12 @@
|
||||
"hsts": "Send HSTS (advanced — off by default)",
|
||||
"hstsHelp": "Turn on only when you serve a real certificate. HSTS pins browsers to HTTPS and cannot be undone once sent (with a self-signed cert it is pure footgun). While off, the server sends a clearing signal (max-age=0) so a browser pinned by an earlier build recovers. If you were stuck being redirected to HTTPS, leave this off and load the app once over HTTPS to clear the pin. Requires a restart to apply.",
|
||||
"selfSignedHosts": "Additional certificate hostnames",
|
||||
"minVersion": "Minimum TLS version",
|
||||
"minVersionHelp": "Lowest TLS protocol version the server will negotiate. Requires a restart to apply.",
|
||||
"restartBanner": "Changes to HTTPS settings require a server restart to take effect."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search settings… (e.g. deadline, TLS, python)",
|
||||
"noResults": "No matching settings"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"generating": "{{name}} 生成中…",
|
||||
"processing": "処理中…",
|
||||
"agentResponding": "エージェントが応答を生成中...",
|
||||
"llmWaiting": "LLM の応答を待っています…",
|
||||
"llmThinking": "思考中… ({{chars}} 文字)",
|
||||
"llmRetrying": "LLM リトライ待ち {{attempt}}/{{max}} ({{reason}})",
|
||||
"llmRecovering": "コンテキストを整理中… ({{stage}})",
|
||||
"attemptBadge": "試行 {{attempt}}/{{max}}",
|
||||
"attemptBadgeTitle": "前回の中断理由: {{reason}}",
|
||||
"sendFailed": "送信に失敗しました",
|
||||
"interjectHint": "エージェント実行中 — メッセージで指示を送れます",
|
||||
"agentRunningWait": "エージェントがタスクを実行中です。少々お待ちください。",
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
"pptxLabel": "ReadPPTX 最大サイズ",
|
||||
"pptxHelp": "ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB)",
|
||||
"pptxUncompressedLabel": "ReadPPTX 展開後サイズ上限",
|
||||
"pptxUncompressedHelp": "PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)"
|
||||
"pptxUncompressedHelp": "PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)",
|
||||
"msgLabel": "ReadMsg 最大サイズ",
|
||||
"msgHelp": "ReadMsg が受け付ける .msg(Outlook メール)の最大ファイルサイズ(デフォルト: 25 MB)"
|
||||
},
|
||||
"uploads": {
|
||||
"sectionHelp": "UI からのアップロード API のリクエスト body 上限 (MB)",
|
||||
@@ -254,6 +256,8 @@
|
||||
"enable": "Enable Gateway",
|
||||
"listenPortHelp": "同 process 時はこの値は使われません: worker UI と同じポート ({{port}}) を共有します。AAO_MODE=gateway で別 process 起動した場合のみ有効。",
|
||||
"separateDeploy": "別 process deploy:",
|
||||
"internalTeamsLabel": "内部チーム",
|
||||
"internalTeamsHelp": "内部として扱うチーム名(カンマ区切り)。ここに載るチームの通信は usage の課金・メータリングから除外される。この一覧はサーバー側で権威を持ち、クライアント指定のヘッダは使わない。",
|
||||
"backendsHelp1": "ルーティング先の llama-server / Ollama / vLLM など。Gateway は worker が送る role を担う backend のうち最も空いているものに割り振ります (roles 未設定の backend は全 role 対応)。role を担う backend が無い場合のみ request.model = id/model の厳密一致にフォールバックします。",
|
||||
"backendsHelp2": "api_key の保存形式: フォームで入力した値は config.yaml に平文で保存されます。${VAR} 形式の env var 参照はフォーム保存時に literal 文字列として保存されるため、env 経由で渡したい場合は config.yaml を直接編集してください。",
|
||||
"backendsEmpty": "backend が未登録です。最低 1 つ追加してください。",
|
||||
@@ -524,11 +528,16 @@
|
||||
"apiKeyDirectHelp": "Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。",
|
||||
"modelHelp": "endpoint が /models を返せば dropdown に候補が出ます。出ない場合 (auth が必要、proxy 越し等) は直接入力してください。",
|
||||
"maxConcurrency": "最大同時実行数",
|
||||
"healthcheckInterval": "ヘルスチェック間隔(秒)",
|
||||
"healthcheckIntervalHelp": "このワーカーのヘルスチェック間隔(秒)。空欄で既定値。",
|
||||
"enabled": "有効",
|
||||
"vlmTitle": "VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用",
|
||||
"returnProgress": "プロンプト処理進捗",
|
||||
"returnProgressTitle": "llama.cpp (llama-server) 専用: プロンプト評価の進捗をストリームし、チャットに「プロンプト処理中 n%」を表示します。他のバックエンドではリクエストが拒否され得るためオフのままにしてください",
|
||||
"addWorker": "+ Worker を追加",
|
||||
"globalTitle": "Global LLM Settings",
|
||||
"timeoutHelp": "LLM リクエストのタイムアウト (分)。デフォルト: 10",
|
||||
"maxStreamHelp": "1 回の LLM 呼び出しの総時間ハード上限(分、リトライ込み)。Timeout と違いチャンク受信でリセットしないので、止まらない暴走生成も打ち切れる。空欄で Timeout×2、0 で無効(非推奨)。",
|
||||
"retryTitle": "Retry (per-call HTTP)",
|
||||
"maxAttemptsHelp": "1 回の LLM API 呼び出しでの最大試行回数",
|
||||
"backoffHelp": "各リトライ間の待機時間 (ms)。配列順に消費されます。",
|
||||
@@ -581,7 +590,8 @@
|
||||
"historyEnable": "履歴の自動要約を有効化",
|
||||
"historyEnableHelp": "古い会話履歴を自動で要約して context を節約。デフォルト: 有効",
|
||||
"tailTurnsHelp": "常に保持する直近の assistant+tool ターン数。デフォルト: 2",
|
||||
"preserveRecentHelp": "要約せず温存する直近メッセージのトークン予算。デフォルト: 8000"
|
||||
"preserveRecentHelp": "要約せず温存する直近メッセージのトークン予算。デフォルト: 8000",
|
||||
"reserveCapHelp": "compact-and-continue 発火前に prompt の上へ確保する headroom の上限。大コンテキストモデルが headroom を使い切れるようにする。デフォルト: 32000"
|
||||
},
|
||||
"mcp": {
|
||||
"intro": "外部 MCP (Model Context Protocol) サーバーの接続・実行に関する設定です。接続先サーバーを追加する場合は、各タスクまたは設定から MCP サーバー URL を指定してください。",
|
||||
@@ -677,7 +687,15 @@
|
||||
"concurrencyHelp": "同時実行可能なジョブ数",
|
||||
"maxMovementsHelp": "1ジョブあたりの最大 movement 数",
|
||||
"maxAttemptsHelp": "ジョブ失敗時の最大リトライ回数。デフォルト: 3",
|
||||
"backoffHelp": "リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900"
|
||||
"backoffHelp": "リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900",
|
||||
"pythonPanel": {
|
||||
"title": "Python サンドボックスのパッケージ",
|
||||
"intro": "エージェントの python は Bash サンドボックス内で動きます。import できるのは事前ベイク済みのパッケージだけです。",
|
||||
"preinstalled": "事前インストール例: pandas, numpy, openpyxl, python-docx, pymupdf。権威ある一覧は runtime/python-requirements.txt です。",
|
||||
"blocked": "サンドボックス内の pip install は現在ブロックされています(サンドボックスはネットワークが遮断されているため)。",
|
||||
"howToAdd": "パッケージを増やすには、管理者が runtime/python-requirements.txt に追記し、ホストを再プロビジョニングします(scripts/prebake-python.sh)。",
|
||||
"roadmap": "サンドボックス内で pip を直接使える機能は、今後のリリースで追加予定です。"
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"limitPlaceholder": "auto (Ollama API から取得)",
|
||||
@@ -739,7 +757,17 @@
|
||||
"askSubtasks": {
|
||||
"askMaxHelp": "1 Job あたりの ASK(ユーザーへの質問)上限",
|
||||
"maxDepthHelp": "サブタスクのネスト最大深度",
|
||||
"maxPerParentHelp": "1 つの親ジョブが spawn できるサブタスクの最大数。デフォルト: 10"
|
||||
"maxPerParentHelp": "1 つの親ジョブが spawn できるサブタスクの最大数。デフォルト: 10",
|
||||
"spawnStaggerHelp": "SpawnSubTask を連続発火するときの間引き遅延(ms)。バースト時に GPU 優先度を独占しないため。0 で無効。デフォルト: 1000"
|
||||
},
|
||||
"skillsQuota": {
|
||||
"title": "スキルのクォータ",
|
||||
"intro": "スキルストアのユーザー単位の上限。1 ユーザーが入れられるスキル数とストアの最大サイズを制限します。",
|
||||
"maxPerUserHelp": "ユーザーあたりの最大スキル数。デフォルト: 50",
|
||||
"maxSkillSizeHelp": "1 スキルの最大サイズ(KB)。デフォルト: 64",
|
||||
"maxTotalSizeHelp": "1 ユーザーの全スキル合計の最大サイズ(MB)。デフォルト: 5",
|
||||
"maxSystemSkillsHelp": "システムスキルの最大数。デフォルト: 100",
|
||||
"maxIndexCharsHelp": "スキルインデックスの最大文字数。デフォルト: 2000"
|
||||
},
|
||||
"namespaceEditor": {
|
||||
"addAriaDisabled": "新規追加は無効化されています",
|
||||
@@ -776,6 +804,12 @@
|
||||
"hsts": "HSTS を送出(上級者向け・既定オフ)",
|
||||
"hstsHelp": "正式な証明書を導入している場合のみオンにしてください。HSTS はブラウザを HTTPS に固定し、一度送ると後からオフにしても解除できません(自己署名では害だけが残ります)。オフのままなら、サーバーは過去に固定されたブラウザを解除するための信号(max-age=0)を送ります。以前 HTTPS にずっとリダイレクトされていた場合は、このスイッチをオフのまま一度 HTTPS でアプリを開けば固定が解除されます。反映には再起動が必要です。",
|
||||
"selfSignedHosts": "証明書の追加ホスト名",
|
||||
"minVersion": "最小 TLS バージョン",
|
||||
"minVersionHelp": "サーバーがネゴシエートする最小の TLS プロトコルバージョン。反映には再起動が必要です。",
|
||||
"restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "設定を検索…(例: デッドライン, TLS, python)",
|
||||
"noResults": "一致する設定はありません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +103,54 @@ describe('summarizeTraceEvent', () => {
|
||||
expect(result).toBe('piece=chat');
|
||||
});
|
||||
});
|
||||
|
||||
// 観測性 Phase 1: LLM 呼び出しログの診断カラム。
|
||||
describe('summarizeTraceEvent — llm observability', () => {
|
||||
it('llm_call_end includes error class, retries, thinking chars and backend', () => {
|
||||
const line = summarizeTraceEvent({
|
||||
kind: 'llm_call_end',
|
||||
payload: {
|
||||
durationMs: 1200, promptTokens: 100, completionTokens: 5, toolCalls: 0, textChars: 0,
|
||||
hadError: true, errorClass: 'gateway_timeout', retries: 2, thinkingChars: 4200, backendId: 'backend-2',
|
||||
},
|
||||
});
|
||||
expect(line).toContain('⚠ gateway_timeout');
|
||||
expect(line).toContain('retries=2');
|
||||
expect(line).toContain('think=4200c');
|
||||
expect(line).toContain('@backend-2');
|
||||
});
|
||||
|
||||
it('llm_call_end appends HTTP status for http errors', () => {
|
||||
const line = summarizeTraceEvent({
|
||||
kind: 'llm_call_end',
|
||||
payload: { durationMs: 10, toolCalls: 0, textChars: 0, hadError: true, errorClass: 'http', httpStatus: 500 },
|
||||
});
|
||||
expect(line).toContain('⚠ http HTTP 500');
|
||||
});
|
||||
|
||||
it('llm_call_end stays compact on the happy path', () => {
|
||||
const line = summarizeTraceEvent({
|
||||
kind: 'llm_call_end',
|
||||
payload: { durationMs: 800, promptTokens: 10, completionTokens: 2, toolCalls: 1, textChars: 0, hadError: false, retries: 0, thinkingChars: 0 },
|
||||
});
|
||||
expect(line).not.toContain('retries=');
|
||||
expect(line).not.toContain('think=');
|
||||
expect(line).not.toContain('⚠');
|
||||
});
|
||||
|
||||
it('summarizes llm_call_retry rows', () => {
|
||||
const line = summarizeTraceEvent({
|
||||
kind: 'llm_call_retry',
|
||||
payload: { attempt: 1, maxAttempts: 3, errorClass: 'http', httpStatus: 503, delayMs: 2000 },
|
||||
});
|
||||
expect(line).toBe('retry 1/3 http HTTP 503 wait=2.0s');
|
||||
});
|
||||
|
||||
it('summarizes context_recovery rows with truncated detail', () => {
|
||||
const line = summarizeTraceEvent({
|
||||
kind: 'context_recovery',
|
||||
payload: { stage: 'summarize_failed', detail: 'no middle turns to summarize' },
|
||||
});
|
||||
expect(line).toBe('summarize_failed: no middle turns to summarize');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,8 +51,18 @@ export function summarizeTraceEvent(event: TraceEventShape): string {
|
||||
: (p.textChars as number) > 0
|
||||
? ` text=${p.textChars}c`
|
||||
: '';
|
||||
return `${fmtDuration(Number(p.durationMs ?? 0))}${tokens}${shape}${p.hadError ? ' ⚠' : ''}`;
|
||||
const thinking = (p.thinkingChars as number) > 0 ? ` think=${p.thinkingChars}c` : '';
|
||||
const retries = (p.retries as number) > 0 ? ` retries=${p.retries}` : '';
|
||||
const backend = typeof p.backendId === 'string' && p.backendId ? ` @${p.backendId}` : '';
|
||||
const error = p.hadError
|
||||
? ` ⚠ ${String(p.errorClass ?? 'error')}${typeof p.httpStatus === 'number' ? ` HTTP ${p.httpStatus}` : ''}`
|
||||
: '';
|
||||
return `${fmtDuration(Number(p.durationMs ?? 0))}${tokens}${shape}${thinking}${retries}${backend}${error}`;
|
||||
}
|
||||
case 'llm_call_retry':
|
||||
return `retry ${p.attempt ?? '?'}/${p.maxAttempts ?? '?'} ${String(p.errorClass ?? '?')}${typeof p.httpStatus === 'number' ? ` HTTP ${p.httpStatus}` : ''} wait=${fmtDuration(Number(p.delayMs ?? 0))}`;
|
||||
case 'context_recovery':
|
||||
return `${String(p.stage ?? '?')}${p.detail ? `: ${String(p.detail).slice(0, 120)}` : ''}`;
|
||||
case 'cache_set':
|
||||
return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`;
|
||||
case 'cache_hit':
|
||||
|
||||
@@ -27,6 +27,7 @@ const SETTINGS_SECTIONS = [
|
||||
'context',
|
||||
'safety',
|
||||
'reflection',
|
||||
'skills-quota',
|
||||
// Tools group (sub-sections)
|
||||
'tools-web',
|
||||
'tools-browser',
|
||||
|
||||
Reference in New Issue
Block a user