This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user