This commit is contained in:
+48
-1
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "実行中",
|
||||
|
||||
@@ -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": "キャンセル",
|
||||
|
||||
Reference in New Issue
Block a user