sync: update from private repo (2ef0fa6)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-17 05:23:28 +00:00
parent 517142c61d
commit 1602d52510
42 changed files with 3387 additions and 64 deletions
+48 -1
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSetupState } from './hooks/useSetupState';
import { SetupWizard } from './components/setup/SetupWizard';
import { LocalTask, type Visibility } from './api';
import { useUrlState } from './hooks/useUrlState';
import { useToast } from './hooks/useToast';
@@ -119,6 +121,51 @@ function AuthenticatedApp() {
const authEnabled = auth.mode !== 'disabled';
const user = auth.mode === 'authenticated' ? auth.user : null;
return <SetupGate isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
}
// First-run gate: when the runtime has no usable LLM, show the full-screen
// setup wizard instead of the app. Fail-open — if the status check errors, fall
// through to the app rather than blocking it.
function SetupGate({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabled: boolean; user: AuthUser | null }) {
const { data: setup, isLoading } = useSetupState();
const queryClient = useQueryClient();
if (isLoading) {
return (
<div className="h-dvh flex items-center justify-center bg-slate-50">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (setup?.needsSetup) {
const canConfigure = !setup.authActive || isAdmin;
if (canConfigure) {
return (
<SetupWizard
status={setup}
onComplete={() => {
queryClient.invalidateQueries({ queryKey: ['setup'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['config'] });
queryClient.invalidateQueries({ queryKey: ['workers'] });
}}
/>
);
}
return (
<div className="h-dvh flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md text-center">
<h1 className="text-lg font-semibold text-slate-800 mb-2">Setup needed</h1>
<p className="text-sm text-slate-600">
This server has no language model configured yet. Ask an administrator to finish setup from Settings.
</p>
</div>
</div>
);
}
return <AppInner isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
}
+29
View File
@@ -231,6 +231,35 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ ta
return data;
}
export interface PromptCoachAxis {
name: string;
score: number;
comment: string;
}
export interface PromptCoachResult {
overall: number;
axes: PromptCoachAxis[];
rewrite: string;
predicted_piece: { name: string; reason: string } | null;
maestro_tips: Array<{ feature: string; suggestion: string }>;
personalized: string[];
}
/**
* On-demand prompt coach: evaluates a draft task prompt before it is submitted.
* Stateless — nothing is persisted. Returns 503 when the coach is unconfigured.
*/
export async function evaluatePrompt(input: { instruction: string; piece?: string }): Promise<PromptCoachResult> {
const res = await fetch(`${BASE}/local/tasks/evaluate-prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to evaluate prompt');
return data as PromptCoachResult;
}
export async function fetchLocalTask(taskId: number): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`);
const data = await res.json();
+30 -6
View File
@@ -6,6 +6,7 @@ import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
import { SubtaskInlineCard } from './SubtaskInlineCard';
import RotatingTips from './RotatingTips';
import { useJobStream } from '../../hooks/useJobStream';
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
@@ -192,6 +193,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
const inputLocked = jobStatus === 'dispatching';
// A job is already accepted but not yet running. The task is NOT idle: a new
// message must fold into this pending job (server reuses it, no duplicate),
// so the composer presents it as an addition rather than a fresh request.
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
const hasActiveJob = isBusy || isPending;
// Release the submit lock once the agent is visibly responding: the new user
// comment is reflected in the list AND the job has been picked up by a worker
@@ -201,10 +207,10 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
if (!submitting) return;
const baseline = submitBaselineRef.current;
if (baseline === null) return;
if (comments.length > baseline && isBusy) {
if (comments.length > baseline && hasActiveJob) {
releaseSubmitting();
}
}, [submitting, comments.length, isBusy]);
}, [submitting, comments.length, hasActiveJob]);
// Clear the safety-net timeout on unmount.
useEffect(() => {
@@ -383,6 +389,9 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
)}
</div>
)}
{/* General rotating tips to make the wait useful (running only). */}
{isBusy && <RotatingTips />}
</div>
</div>
@@ -406,17 +415,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
{/* Composer */}
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-3" style={{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }}>
{isBusy && (
{hasActiveJob && (
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
canInterject
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
: isPending
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
}`}>
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<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>
<span>{canInterject ? t('pane.interjectHint') : t('pane.agentRunningWait')}</span>
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
</div>
)}
{sendError && !isBusy && (
@@ -468,7 +479,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
onPaste={e => void handlePaste(e)}
rows={2}
disabled={inputLocked}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : t('pane.placeholder.default')}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
/>
{isBusy && onCancel ? (
@@ -498,6 +509,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
{cancelling ? t('pane.stopping') : t('pane.stop')}
</button>
</div>
) : isPending ? (
<button
disabled={submitting || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
title={t('pane.addToQueuedHint')}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t('pane.addToQueued')}
</button>
) : (
<button
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import ja from '../../i18n/locales/ja/chat.json';
import en from '../../i18n/locales/en/chat.json';
// Pure JSON parity check for the rotating-tips strings consumed by
// RotatingTips.tsx. The component itself is DOM + timer based and isn't
// exercised here; this guards the data contract (chat.tips.items) instead.
describe('chat tips (RotatingTips data)', () => {
it('ja and en both define a non-empty tips.items array', () => {
expect(Array.isArray(ja.tips?.items)).toBe(true);
expect(Array.isArray(en.tips?.items)).toBe(true);
expect(ja.tips.items.length).toBeGreaterThan(0);
expect(en.tips.items.length).toBeGreaterThan(0);
});
it('ja and en have the same number of tips (no locale drift)', () => {
expect(ja.tips.items.length).toBe(en.tips.items.length);
});
it('every tip is a non-empty string', () => {
for (const list of [ja.tips.items, en.tips.items]) {
for (const tip of list) {
expect(typeof tip).toBe('string');
expect(tip.trim().length).toBeGreaterThan(0);
}
}
});
it('defines a tips.label in both locales', () => {
expect(typeof ja.tips.label).toBe('string');
expect(typeof en.tips.label).toBe('string');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
/**
* RotatingTips — a subtle, rotating general "did you know" line shown while the
* agent is running, to make the wait useful. Tips are static, curated, and
* localized (chat.tips.items). Mount this only when the job is busy; it stops
* rotating automatically on unmount.
*
* Personalized, prompt-specific coaching is a separate feature (the on-demand
* prompt evaluator in the create dialog) — these tips are general only.
*/
const ROTATE_MS = 7000;
const FADE_MS = 300;
export default function RotatingTips() {
const { t } = useTranslation('chat');
const items = t('tips.items', { returnObjects: true }) as unknown;
const tips = Array.isArray(items) ? (items as string[]) : [];
// Random start so the same tip isn't always first; guarded for empty list.
const [idx, setIdx] = useState(() => (tips.length ? Math.floor(Math.random() * tips.length) : 0));
const [visible, setVisible] = useState(true);
useEffect(() => {
if (tips.length <= 1) return;
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
const rotate = setInterval(() => {
setVisible(false);
const swap = setTimeout(() => {
setIdx((i) => (i + 1) % tips.length);
setVisible(true);
pendingTimeouts.delete(swap);
}, FADE_MS);
pendingTimeouts.add(swap);
}, ROTATE_MS);
return () => {
clearInterval(rotate);
pendingTimeouts.forEach(clearTimeout);
};
}, [tips.length]);
if (tips.length === 0) return null;
return (
<div className="flex justify-start" aria-live="polite">
<div className="inline-flex items-start gap-2 px-2.5 py-1 max-w-[80%] text-2xs text-slate-500 dark:text-slate-400">
<span className="flex-shrink-0 opacity-70" aria-hidden="true">💡</span>
<span className="font-medium flex-shrink-0 text-slate-400 dark:text-slate-500">
{t('tips.label')}
</span>
<span
className={`transition-opacity ease-in-out ${visible ? 'opacity-100' : 'opacity-0'}`}
style={{ transitionDuration: `${FADE_MS}ms` }}
>
{tips[idx]}
</span>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
import { AttachmentDropzone } from './AttachmentDropzone';
import { PromptCoachPanel } from './PromptCoachPanel';
import { ScheduleFields } from './ScheduleFields';
import { usePieceList } from '../../hooks/usePieces';
import { resolvePieceOptions } from '../../lib/splitPieces';
@@ -196,6 +197,13 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
{/* Attachments */}
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
{/* Prompt coach (on-demand draft evaluation) */}
<PromptCoachPanel
body={form.body}
piece={initialPiece ?? form.piece}
onApplyRewrite={(text) => setForm(prev => ({ ...prev, body: text }))}
/>
{/* MCP warnings (always visible when applicable) */}
{missingMcp.length > 0 && (
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
@@ -0,0 +1,142 @@
import { useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { evaluatePrompt, type PromptCoachResult } from '../../api';
interface PromptCoachPanelProps {
/** Current draft body from the dialog's textarea. */
body: string;
/** Selected piece ('auto' = unset). */
piece?: string;
/** Inject the rewrite suggestion back into the textarea. */
onApplyRewrite: (text: string) => void;
}
function scoreColor(score: number, max: number): string {
const ratio = max > 0 ? score / max : 0;
if (ratio >= 0.75) return 'text-emerald-600 dark:text-emerald-400';
if (ratio >= 0.4) return 'text-amber-600 dark:text-amber-400';
return 'text-rose-600 dark:text-rose-400';
}
export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPanelProps) {
const { t } = useTranslation('create');
const mutation = useMutation<PromptCoachResult, Error, void>({
mutationFn: () => evaluatePrompt({ instruction: body.trim(), piece }),
});
// Discard a stale evaluation once the draft (body or piece) changes, so the
// panel never shows a score/rewrite that no longer matches the textarea —
// and "Apply to request" can't overwrite the draft with an outdated rewrite.
const { reset } = mutation;
useEffect(() => {
reset();
}, [body, piece, reset]);
const result = mutation.data;
const disabled = !body.trim() || mutation.isPending;
return (
<div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={disabled}
onClick={() => mutation.mutate()}
className="px-3 py-1.5 border border-accent/40 text-accent rounded-xl text-xs font-bold hover:bg-accent/5 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{mutation.isPending && (
<span className="inline-block w-3 h-3 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
)}
{mutation.isPending ? t('coach.evaluating') : t('coach.evaluate')}
</button>
<span className="text-2xs text-slate-400">{t('coach.hint')}</span>
</div>
{mutation.isError && (
<div className="mt-2 text-xs text-rose-600">{t('coach.failed')}</div>
)}
{result && (
<div className="mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl p-4 bg-slate-50/60 dark:bg-slate-800/40">
{/* Overall score */}
<div className="flex items-baseline gap-2">
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>
<span className={`text-2xl font-extrabold ${scoreColor(result.overall, 100)}`}>{result.overall}</span>
<span className="text-xs text-slate-400">/ 100</span>
</div>
{/* Axes */}
{result.axes.length > 0 && (
<div className="space-y-1.5">
{result.axes.map((ax, i) => (
<div key={i} className="text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-700 dark:text-slate-200">{ax.name}</span>
<span className={`font-bold ${scoreColor(ax.score, 10)}`}>{ax.score}/10</span>
</div>
{ax.comment && <div className="text-slate-500 dark:text-slate-400 mt-0.5">{ax.comment}</div>}
</div>
))}
</div>
)}
{/* Rewrite suggestion */}
{result.rewrite && (
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-bold text-slate-500">{t('coach.rewrite')}</span>
<button
type="button"
onClick={() => onApplyRewrite(result.rewrite)}
className="px-2 py-0.5 rounded bg-accent text-accent-fg text-2xs font-bold hover:bg-accent-deep"
>
{t('coach.applyRewrite')}
</button>
</div>
<div className="text-xs text-slate-700 dark:text-slate-200 whitespace-pre-wrap bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 rounded-lg p-2.5 leading-relaxed">
{result.rewrite}
</div>
</div>
)}
{/* Predicted piece */}
{result.predicted_piece && (
<div className="text-xs">
<span className="font-bold text-slate-500">{t('coach.predictedPiece')}</span>{' '}
<span className="font-semibold text-accent">{result.predicted_piece.name}</span>
{result.predicted_piece.reason && (
<span className="text-slate-500 dark:text-slate-400"> {result.predicted_piece.reason}</span>
)}
</div>
)}
{/* MAESTRO tips */}
{result.maestro_tips.length > 0 && (
<div>
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.maestroTips')}</div>
<ul className="space-y-1">
{result.maestro_tips.map((tip, i) => (
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">
<span className="font-semibold">{tip.feature}</span>
{tip.suggestion && <span className="text-slate-500 dark:text-slate-400"> {tip.suggestion}</span>}
</li>
))}
</ul>
</div>
)}
{/* Personalized notes */}
{result.personalized.length > 0 && (
<div>
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.personalized')}</div>
<ul className="list-disc list-inside space-y-0.5">
{result.personalized.map((note, i) => (
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">{note}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
import { useState } from 'react';
import type { SetupStatus } from '../../hooks/useSetupState';
// Full-screen first-run onboarding for a fresh, no-auth `docker compose up`.
// Configures the LLM connection (required) plus an optional server port and
// auth bootstrap, then applies everything in ONE call to /api/setup/apply.
// See docs/superpowers/specs/2026-06-16-browser-setup-wizard-design.md.
type ConnectionType = 'direct' | 'aao_gateway';
type AuthMode = 'none' | 'local' | 'oauth';
type OAuthProvider = 'google' | 'gitea';
interface ApplyResult {
ok: boolean;
restartRequired: boolean;
adminEmail?: string;
}
const PRIMARY_BTN =
'px-4 py-2 text-sm font-semibold bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50 transition-colors';
const SECONDARY_BTN =
'px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft disabled:opacity-50 transition-colors';
const INPUT =
'w-full px-3 py-2 text-sm border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-accent/40';
const LABEL = 'block text-sm font-medium text-slate-700 mb-1';
function restartCommand(hint: SetupStatus['deployHint']): string {
return hint === 'docker' ? 'docker compose restart' : 'scripts/server.sh restart';
}
export function SetupWizard({ status, onComplete }: { status: SetupStatus; onComplete: () => void }) {
const tokenRequired = status.tokenRequired;
const [token, setToken] = useState('');
// Step 1 — LLM (required)
const [connectionType, setConnectionType] = useState<ConnectionType>('direct');
const [endpoint, setEndpoint] = useState('http://localhost:11434/v1');
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState('');
const [models, setModels] = useState<string[]>([]);
const [probing, setProbing] = useState(false);
const [probeError, setProbeError] = useState<string | null>(null);
// Step 2 — port (optional)
const [port, setPort] = useState('');
// Step 3 — auth (optional)
const [authMode, setAuthMode] = useState<AuthMode>('none');
const [localEmail, setLocalEmail] = useState('');
const [localPassword, setLocalPassword] = useState('');
const [oauthProvider, setOauthProvider] = useState<OAuthProvider>('gitea');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [callbackUrl, setCallbackUrl] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [oauthAdminEmail, setOauthAdminEmail] = useState('');
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [result, setResult] = useState<ApplyResult | null>(null);
function headers(): Record<string, string> {
const h: Record<string, string> = { 'content-type': 'application/json' };
if (tokenRequired && token.trim()) h['x-setup-token'] = token.trim();
return h;
}
async function runProbe() {
setProbing(true);
setProbeError(null);
setModels([]);
try {
const res = await fetch('/api/setup/probe', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ connectionType, endpoint, apiKey: apiKey || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setProbeError(data.error || `connection failed (HTTP ${res.status})`);
return;
}
const list: string[] = Array.isArray(data.models) ? data.models : [];
setModels(list);
if (list.length && !model) setModel(list[0]);
if (!list.length) setProbeError('connected, but no models were returned — enter a model name manually');
} catch (e) {
setProbeError(String(e));
} finally {
setProbing(false);
}
}
function authBlock(): unknown | undefined {
if (authMode === 'local') {
return { mode: 'local', local: { email: localEmail.trim(), password: localPassword } };
}
if (authMode === 'oauth') {
return {
mode: 'oauth',
oauth: {
provider: oauthProvider,
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
callbackUrl: callbackUrl.trim(),
adminEmail: oauthAdminEmail.trim(),
...(oauthProvider === 'gitea' ? { baseUrl: baseUrl.trim() } : {}),
},
};
}
return undefined;
}
const canApply =
(!tokenRequired || token.trim().length > 0) &&
endpoint.trim().length > 0 &&
model.trim().length > 0 &&
(connectionType !== 'aao_gateway' || apiKey.trim().length > 0) &&
(authMode !== 'local' || (localEmail.trim().length > 0 && localPassword.length >= 8)) &&
(authMode !== 'oauth' ||
(clientId.trim() &&
clientSecret.trim() &&
callbackUrl.trim() &&
oauthAdminEmail.trim().includes('@') &&
(oauthProvider !== 'gitea' || baseUrl.trim())));
async function runApply() {
setApplying(true);
setApplyError(null);
try {
const body: Record<string, unknown> = {
llm: { connectionType, endpoint: endpoint.trim(), model: model.trim(), apiKey: apiKey || undefined },
};
if (port.trim()) body.port = Number(port.trim());
const auth = authBlock();
if (auth) body.auth = auth;
const res = await fetch('/api/setup/apply', {
method: 'POST',
headers: headers(),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setApplyError(data.error || `apply failed (HTTP ${res.status})`);
return;
}
const r: ApplyResult = { ok: true, restartRequired: !!data.restartRequired, adminEmail: data.adminEmail };
setResult(r);
if (!r.restartRequired) {
// LLM-only: the wizard's job is done — refresh into the app.
onComplete();
}
} catch (e) {
setApplyError(String(e));
} finally {
setApplying(false);
}
}
// --- Finish screen (restart required) ---
if (result?.restartRequired) {
return (
<Shell>
<h1 className="text-xl font-semibold text-slate-800 mb-2">Almost there one restart needed</h1>
<p className="text-sm text-slate-600 mb-4">
Your LLM connection is live. The port and/or authentication changes take effect after a restart.
</p>
<div className="bg-slate-900 text-slate-100 text-sm rounded-lg px-4 py-3 font-mono mb-4 select-all">
{restartCommand(status.deployHint)}
</div>
{result.adminEmail && (
<p className="text-sm text-slate-600">
After restart, sign in as <span className="font-semibold">{result.adminEmail}</span> with the password you
just set. Change it from <span className="font-medium">Settings Account</span> afterwards.
</p>
)}
</Shell>
);
}
// --- Wizard form ---
return (
<Shell>
<h1 className="text-xl font-semibold text-slate-800 mb-1">Welcome let's get you set up</h1>
<p className="text-sm text-slate-600 mb-6">
Connect a language model to start running tasks. Optionally set the server port and turn on sign-in.
</p>
{tokenRequired && (
<Section title="Setup token" subtitle="Find it in the server logs: “open the UI and enter this setup token”.">
<input className={INPUT} value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste the setup token" />
</Section>
)}
<Section title="1 · Language model" subtitle="Required. This is reflected immediately — no restart.">
<label className={LABEL}>Connection</label>
<select
className={`${INPUT} mb-3`}
value={connectionType}
onChange={(e) => setConnectionType(e.target.value as ConnectionType)}
>
<option value="direct">Direct (Ollama / vLLM / OpenAI-compatible)</option>
<option value="aao_gateway">AAO gateway</option>
</select>
<label className={LABEL}>Endpoint URL</label>
<input className={`${INPUT} mb-3`} value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="http://localhost:11434/v1" />
{connectionType === 'aao_gateway' && (
<>
<label className={LABEL}>API key</label>
<input className={`${INPUT} mb-3`} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-aao-…" />
</>
)}
<div className="flex items-center gap-3 mb-3">
<button className={SECONDARY_BTN} onClick={runProbe} disabled={probing || !endpoint.trim() || (tokenRequired && !token.trim())}>
{probing ? 'Testing' : 'Test connection'}
</button>
{probeError && <span className="text-sm text-red-600">{probeError}</span>}
{!probeError && models.length > 0 && <span className="text-sm text-green-600">Found {models.length} model(s)</span>}
</div>
<label className={LABEL}>Model</label>
{models.length > 0 ? (
<select className={INPUT} value={model} onChange={(e) => setModel(e.target.value)}>
{!models.includes(model) && model && <option value={model}>{model}</option>}
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input className={INPUT} value={model} onChange={(e) => setModel(e.target.value)} placeholder="e.g. llama3.1:8b" />
)}
</Section>
<Section title="2 · Server port" subtitle="Optional. Leave blank to keep the current port. Takes effect after a restart.">
<input className={INPUT} value={port} onChange={(e) => setPort(e.target.value)} placeholder={String(status.port)} inputMode="numeric" />
</Section>
<Section title="3 · Sign-in" subtitle="Optional. Leave off to stay open (no login). Takes effect after a restart.">
<select className={`${INPUT} mb-3`} value={authMode} onChange={(e) => setAuthMode(e.target.value as AuthMode)}>
<option value="none">No sign-in (open)</option>
<option value="local">Email + password (first admin)</option>
<option value="oauth">OAuth (Google / Gitea)</option>
</select>
{authMode === 'local' && (
<>
<label className={LABEL}>Admin email</label>
<input className={`${INPUT} mb-3`} value={localEmail} onChange={(e) => setLocalEmail(e.target.value)} placeholder="[email protected]" />
<label className={LABEL}>Admin password (min 8 chars)</label>
<input className={INPUT} type="password" value={localPassword} onChange={(e) => setLocalPassword(e.target.value)} />
</>
)}
{authMode === 'oauth' && (
<>
<label className={LABEL}>Provider</label>
<select className={`${INPUT} mb-3`} value={oauthProvider} onChange={(e) => setOauthProvider(e.target.value as OAuthProvider)}>
<option value="gitea">Gitea</option>
<option value="google">Google</option>
</select>
<label className={LABEL}>Client ID</label>
<input className={`${INPUT} mb-3`} value={clientId} onChange={(e) => setClientId(e.target.value)} />
<label className={LABEL}>Client secret</label>
<input className={`${INPUT} mb-3`} type="password" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} />
<label className={LABEL}>Callback URL</label>
<input className={`${INPUT} mb-3`} value={callbackUrl} onChange={(e) => setCallbackUrl(e.target.value)} placeholder="https://your-host/auth/gitea/callback" />
<label className={LABEL}>Admin email</label>
<input className={`${INPUT} mb-3`} value={oauthAdminEmail} onChange={(e) => setOauthAdminEmail(e.target.value)} placeholder="[email protected] — becomes admin on first login" />
{oauthProvider === 'gitea' && (
<>
<label className={LABEL}>Gitea base URL</label>
<input className={INPUT} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://gitea.example.com" />
</>
)}
</>
)}
</Section>
{applyError && <p className="text-sm text-red-600 mb-3">{applyError}</p>}
<div className="flex justify-end pt-2">
<button className={PRIMARY_BTN} onClick={runApply} disabled={applying || !canApply}>
{applying ? 'Applying' : 'Apply & start'}
</button>
</div>
</Shell>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="h-dvh overflow-y-auto bg-slate-50 flex items-start justify-center py-10 px-4">
<div className="w-full max-w-xl bg-white border border-slate-200 rounded-2xl shadow-sm p-8">{children}</div>
</div>
);
}
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<div className="mb-6 pb-6 border-b border-slate-100 last:border-b-0">
<h2 className="text-sm font-semibold text-slate-800">{title}</h2>
{subtitle && <p className="text-xs text-slate-500 mb-3">{subtitle}</p>}
{children}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
export interface SetupStatus {
/** True when the runtime has no usable LLM worker → show the wizard. */
needsSetup: boolean;
/** True when an OAuth provider or local auth is active. */
authActive: boolean;
/** Resolved listen port (for the restart hint). */
port: number;
/** How the server was started, for tailored restart instructions. */
deployHint: 'docker' | 'source';
/** True when a setup token must be supplied with mutating setup calls. */
tokenRequired: boolean;
}
/**
* Polls GET /api/setup/status. Short staleTime so the wizard disappears
* promptly once the LLM is configured (the server recomputes needsSetup per
* request). Never throws into the gate — on error we treat it as "no wizard".
*/
export function useSetupState() {
return useQuery<SetupStatus>({
queryKey: ['setup', 'status'],
queryFn: async () => {
const res = await fetch('/api/setup/status');
if (!res.ok) throw new Error(`setup status ${res.status}`);
return res.json();
},
retry: false,
staleTime: 10_000,
});
}
+28
View File
@@ -8,11 +8,15 @@
"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.",
"queuedHint": "Queued — starting shortly",
"addToQueued": "Add to task",
"addToQueuedHint": "Adds this instruction to the queued task (no new task is created)",
"resend": "Resend",
"attachFile": "Attach a file",
"placeholder": {
"dispatching": "Assigning job...",
"interject": "Instruct the running agent...",
"queued": "Add an instruction to the queued task…",
"default": "Type a message... (Ctrl+Enter to send)"
},
"interject": "Interject",
@@ -21,6 +25,30 @@
"stop": "Stop",
"send": "Send"
},
"tips": {
"label": "TIP",
"items": [
"Attachments land in input/, and results are written to output/.",
"You can send a message while the agent runs to add instructions mid-task.",
"Repeating the same work? Register it as a recurring run from Schedules.",
"Put standing rules in AGENTS.md and they apply automatically to every task.",
"Save facts worth keeping to memory, and future tasks will reference them.",
"Split large research into subtasks to run them in parallel and finish faster.",
"Register an SSH connection to let the agent operate remote servers.",
"Turn frequent procedures into a Skill so the agent can call them.",
"Ingest documents into Knowledge to search across them.",
"The piece is auto-selected from your prompt, but you can also pick it manually at creation.",
"Attach images or PDFs and the agent will read them as it works.",
"The more specific your instructions, the better the result — add the format and constraints you want.",
"Name a skill (\"use the X skill\") to make the agent use it for sure.",
"Each piece exposes different tools. If a tool is missing, try switching the piece.",
"You can change a task's piece later — switch it if the agent behaves unexpectedly.",
"Not sure how something works? The Help docs explain each feature.",
"Connect an MCP server to call external tools directly from the agent.",
"The Usage tab shows LLM consumption per user and per model.",
"Web search results aren't always current — open the source page to verify anything important."
]
},
"subtask": {
"title": "Subtasks",
"running": "Running",
+12
View File
@@ -51,6 +51,18 @@
"runAt": "Run at",
"weekdays": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
},
"coach": {
"evaluate": "Evaluate prompt",
"evaluating": "Evaluating...",
"hint": "Score & improve your draft before sending",
"failed": "Evaluation failed. Please try again shortly.",
"overall": "Overall",
"rewrite": "Suggested rewrite",
"applyRewrite": "Apply to request",
"predictedPiece": "Predicted task type:",
"maestroTips": "MAESTRO features you could use",
"personalized": "Notes for you"
},
"attachments": { "title": "Attachments", "hint": "Drag & drop or choose files" },
"errors": { "bodyRequired": "Request is required", "scheduleFailed": "Failed to create schedule" },
"cancel": "Cancel",
+28
View File
@@ -8,11 +8,15 @@
"sendFailed": "送信に失敗しました",
"interjectHint": "エージェント実行中 — メッセージで指示を送れます",
"agentRunningWait": "エージェントがタスクを実行中です。少々お待ちください。",
"queuedHint": "実行待ち … まもなく開始します",
"addToQueued": "追加で送信",
"addToQueuedHint": "実行待ちのタスクにこの指示を追加します(新しいタスクは作成しません)",
"resend": "再送信",
"attachFile": "ファイルを添付",
"placeholder": {
"dispatching": "ジョブ割り当て中...",
"interject": "実行中のエージェントに指示...",
"queued": "実行待ちのタスクに指示を追加…",
"default": "メッセージを入力... (Ctrl+Enter で送信)"
},
"interject": "割り込み",
@@ -21,6 +25,30 @@
"stop": "停止",
"send": "送信"
},
"tips": {
"label": "TIP",
"items": [
"添付ファイルは input/ に入り、成果物は output/ に書き出されます。",
"実行中でもメッセージを送れば、エージェントに追加指示を割り込ませられます。",
"同じ作業を繰り返すなら、スケジュールから定期実行に登録できます。",
"AGENTS.md に方針を書いておくと、毎回のタスクに自動で反映されます。",
"覚えておいてほしい事実は memory に残すと、次回以降のタスクで参照されます。",
"大きめの調査はサブタスクに分割すると、並列で速く進みます。",
"リモートサーバーの操作は、SSH 接続を登録するとエージェントから実行できます。",
"よく使う手順はスキルとして登録すると、エージェントが呼び出せます。",
"ナレッジにドキュメントを取り込むと、横断検索で参照できます。",
"piece は内容から自動で選ばれますが、作成時に手動で指定もできます。",
"画像や PDF も添付すれば、エージェントが読み取って作業します。",
"指示は具体的なほど精度が上がります。成果物の形式や条件も添えてみてください。",
"「○○のスキルを使って」と名前を挙げると、そのスキルを確実に使わせられます。",
"piece ごとに使えるツールが違います。必要なツールが無いときは piece を切り替えてみてください。",
"タスクの piece は後からでも変更できます。想定と違う動きをしたら切り替えを。",
"使い方に迷ったら、ヘルプのドキュメントに各機能の説明があります。",
"MCP サーバーを接続すると、外部ツールをエージェントから直接呼べます。",
"使用量タブで、ユーザーやモデルごとの LLM 利用状況を確認できます。",
"Web 検索の結果は最新とは限りません。重要な情報は元ページを開いて裏取りを。"
]
},
"subtask": {
"title": "サブタスク",
"running": "実行中",
+12
View File
@@ -51,6 +51,18 @@
"runAt": "実行日時",
"weekdays": ["日曜", "月曜", "火曜", "水曜", "木曜", "金曜", "土曜"]
},
"coach": {
"evaluate": "プロンプトを評価",
"evaluating": "評価中...",
"hint": "送信前に下書きを採点・改善",
"failed": "評価に失敗しました。しばらくして再試行してください。",
"overall": "総合スコア",
"rewrite": "改善案",
"applyRewrite": "本文に反映",
"predictedPiece": "推定タスクタイプ:",
"maestroTips": "活用できる MAESTRO 機能",
"personalized": "あなた向けの指摘"
},
"attachments": { "title": "添付ファイル", "hint": "ドラッグ&ドロップまたはファイル選択" },
"errors": { "bodyRequired": "依頼内容は必須です", "scheduleFailed": "スケジュール作成に失敗しました" },
"cancel": "キャンセル",