Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 72 additions & 10 deletions frontend/src/features/assets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ import { ProfileForm, type ProfileFormCopy } from "../builds/page";
import { SectionSkeleton } from "../../components/ui/section-skeleton";

const text = localeMessageMap<Record<string, string>>("assetsText");
type AssetTab = "ai" | "test-design" | "run-setup";
const assetTabIds: AssetTab[] = ["ai", "test-design", "run-setup"];
const assetTabFromLocation = (): AssetTab => {
const value = new URLSearchParams(window.location.search).get("tab");
return assetTabIds.includes(value as AssetTab) ? (value as AssetTab) : "ai";
};
const testBlank: TargetTestCaseSet = {
id: "",
name: "",
Expand Down Expand Up @@ -1063,6 +1069,7 @@ function LegacyAssetsPage({
onUpdateWorkflow,
onDelete,
usage,
activeTab,
}: {
locale: Locale;
workflows: Workflow[];
Expand All @@ -1075,6 +1082,7 @@ function LegacyAssetsPage({
onUpdateWorkflow: (id: string, values: unknown) => Promise<unknown>;
onDelete: (kind: "template" | "test-set" | "runner" | "workflow", id: string) => void;
usage: AssetUsage;
activeTab: "ai" | "test-design" | "run-setup";
}) {
const createLabel = locales[locale].ui.create,
l: Record<string, string> = {
Expand Down Expand Up @@ -1118,7 +1126,7 @@ function LegacyAssetsPage({
);
return (
<>
<Catalog
{activeTab === "ai" && <Catalog
locale={locale}
loading={loading}
emptyHint={l.emptyTemplates}
Expand Down Expand Up @@ -1153,8 +1161,8 @@ function LegacyAssetsPage({
onDelete={() => onDelete("template", item.id)}
/>
))}
</Catalog>
<Catalog
</Catalog>}
{activeTab === "test-design" && <Catalog
locale={locale}
loading={loading}
emptyHint={l.emptyTests}
Expand All @@ -1179,8 +1187,8 @@ function LegacyAssetsPage({
onDelete={() => onDelete("test-set", item.id)}
/>
))}
</Catalog>
<Catalog
</Catalog>}
{activeTab === "run-setup" && <Catalog
locale={locale}
loading={loading}
showRunners
Expand Down Expand Up @@ -1217,7 +1225,7 @@ function LegacyAssetsPage({
onDelete={() => onDelete("workflow", item.id)}
/>
))}
</Catalog>
</Catalog>}
{template && (
<PromptTemplateEditor
locale={locale}
Expand Down Expand Up @@ -1917,7 +1925,8 @@ function EnvironmentCatalog({
);
}

function PersonaCatalog({ builds, onRefresh }: { builds: Build[]; onRefresh: () => Promise<unknown> }) {
function PersonaCatalog({ builds, onRefresh, locale }: { builds: Build[]; onRefresh: () => Promise<unknown>; locale: Locale }) {
const t = text[locale];
const [items, setItems] = useState<Persona[]>([]), [draft, setDraft] = useState<Persona | null>(null), [error, setError] = useState("");
const load = () => api<Persona[]>("/api/personas").then(setItems);
useEffect(() => { void load(); }, []);
Expand All @@ -1928,7 +1937,7 @@ function PersonaCatalog({ builds, onRefresh }: { builds: Build[]; onRefresh: ()
.then(() => { setDraft(null); setError(""); return Promise.all([load(), onRefresh()]); })
.catch((value) => setError(value.message));
};
return <section className="panel app-settings"><div className="panel-title-action"><PanelHeader title="Personas" /><button className="approve" onClick={() => setDraft({ id: `persona-${Date.now()}`, name: "", locale: "en-US", timezone: "UTC", activity_windows: [], goals: [""], constraints: [], context: {} })}><Plus size={14} />Create</button></div><p className="hint">Reusable user perspectives, goals, constraints, and product-specific context. Runtime identity and memory are never stored here.</p><AssetCatalog emptyHint="No personas yet.">{items.map((item) => <AssetRow key={item.id} name={item.name} detail={`${item.locale} · ${item.timezone} · ${item.goals.length} goals`} usageCount={builds.filter((build) => build.persona_ids?.includes(item.id)).length} onClick={() => setDraft(item)} onDelete={() => api(`/api/personas/${item.id}`, "DELETE").then(() => Promise.all([load(), onRefresh()]).then(() => undefined)).catch((value) => setError(value.message))} />)}</AssetCatalog>{draft && <Modal open title="Persona" onClose={() => setDraft(null)}><div className="modal-form"><label className="modal-setting-row"><span>ID</span><input disabled={items.some((item) => item.id === draft.id)} value={draft.id} onChange={(event) => setDraft({ ...draft, id: event.target.value })} /></label><label className="modal-setting-row"><span>Name</span><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label><label className="modal-setting-row"><span>Locale</span><input value={draft.locale} onChange={(event) => setDraft({ ...draft, locale: event.target.value })} /></label><label className="modal-setting-row"><span>Timezone</span><input value={draft.timezone} onChange={(event) => setDraft({ ...draft, timezone: event.target.value })} /></label><label className="modal-setting-row"><span>Goals (one per line)</span><textarea value={draft.goals.join("\n")} onChange={(event) => setDraft({ ...draft, goals: event.target.value.split("\n").filter(Boolean) })} /></label><label className="modal-setting-row"><span>Constraints (one per line)</span><textarea value={draft.constraints.join("\n")} onChange={(event) => setDraft({ ...draft, constraints: event.target.value.split("\n").filter(Boolean) })} /></label><label className="modal-setting-row"><span>Activity windows / context (JSON)</span><textarea value={JSON.stringify({ activity_windows: draft.activity_windows, context: draft.context }, null, 2)} onChange={(event) => { try { const value = JSON.parse(event.target.value); setDraft({ ...draft, activity_windows: value.activity_windows ?? [], context: value.context ?? {} }); setError(""); } catch { setError("Activity windows and context must be valid JSON"); } }} /></label><div className="modal-actions"><small>{error}</small><button className="approve" onClick={save}>Save</button></div></div></Modal>}</section>;
return <section className="panel app-settings"><div className="panel-title-action"><div className="panel-title-action__copy"><PanelHeader title={<SectionInfo title={t.personas} description={t.personaDescription} />} /><p className="hint section-description">{t.personaDescription}</p></div><button className="approve" onClick={() => setDraft({ id: `persona-${Date.now()}`, name: "", locale: "en-US", timezone: "UTC", activity_windows: [], goals: [""], constraints: [], context: {} })}><Plus size={14} />{locales[locale].ui.create}</button></div><AssetCatalog locale={locale} emptyHint={t.emptyPersonas}>{items.map((item) => <AssetRow key={item.id} name={item.name} detail={`${item.locale} · ${item.timezone} · ${item.goals.length} ${t.goals.toLowerCase()}`} usageCount={builds.filter((build) => build.persona_ids?.includes(item.id)).length} locale={locale} onClick={() => setDraft(item)} onDelete={() => api(`/api/personas/${item.id}`, "DELETE").then(() => Promise.all([load(), onRefresh()]).then(() => undefined)).catch((value) => setError(value.message))} />)}</AssetCatalog>{draft && <Modal open title={t.persona} onClose={() => setDraft(null)}><div className="modal-form"><label className="modal-setting-row"><span>{t.id}</span><input disabled={items.some((item) => item.id === draft.id)} value={draft.id} onChange={(event) => setDraft({ ...draft, id: event.target.value })} /></label><label className="modal-setting-row"><span>{t.name}</span><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label><label className="modal-setting-row"><span>{t.locale}</span><input value={draft.locale} onChange={(event) => setDraft({ ...draft, locale: event.target.value })} /></label><label className="modal-setting-row"><span>{t.timezone}</span><input value={draft.timezone} onChange={(event) => setDraft({ ...draft, timezone: event.target.value })} /></label><label className="modal-setting-row"><span>{t.goals} ({t.goalsHint})</span><textarea value={draft.goals.join("\n")} onChange={(event) => setDraft({ ...draft, goals: event.target.value.split("\n").filter(Boolean) })} /></label><label className="modal-setting-row"><span>{t.constraints} ({t.constraintsHint})</span><textarea value={draft.constraints.join("\n")} onChange={(event) => setDraft({ ...draft, constraints: event.target.value.split("\n").filter(Boolean) })} /></label><label className="modal-setting-row"><span>{t.activityContext} ({t.activityContextHint})</span><textarea value={JSON.stringify({ activity_windows: draft.activity_windows, context: draft.context }, null, 2)} onChange={(event) => { try { const value = JSON.parse(event.target.value); setDraft({ ...draft, activity_windows: value.activity_windows ?? [], context: value.context ?? {} }); setError(""); } catch { setError(t.invalidPersonaJson); } }} /></label><div className="modal-actions"><small>{error}</small><button className="approve" onClick={save}>{t.save}</button></div></div></Modal>}</section>;
}

export function AssetsPage({
Expand Down Expand Up @@ -1977,6 +1986,28 @@ export function AssetsPage({
) => void;
}) {
const usage = useMemo(() => buildAssetUsage(builds), [builds]);
const tabs = [
{ id: "ai" as const, label: text[locale].tabAI },
{ id: "test-design" as const, label: text[locale].tabTestDesign },
{ id: "run-setup" as const, label: text[locale].tabRunSetup },
];
const [activeTab, setActiveTab] = useState<AssetTab>(assetTabFromLocation);
useEffect(() => {
const sync = () => setActiveTab(assetTabFromLocation());
window.addEventListener("popstate", sync);
return () => window.removeEventListener("popstate", sync);
}, []);
const selectTab = (tab: AssetTab) => {
if (tab === activeTab) return;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
window.history.pushState(null, "", `${url.pathname}${url.search}${url.hash}`);
setActiveTab(tab);
};
const moveTab = (offset: number) => {
const index = tabs.findIndex((tab) => tab.id === activeTab);
selectTab(tabs[(index + offset + tabs.length) % tabs.length].id);
};
if (loading) return <>
<SectionSkeleton rows={3} />
<SectionSkeleton rows={3} />
Expand All @@ -1987,6 +2018,29 @@ export function AssetsPage({
</>;
return (
<>
<div className="asset-tabs" role="tablist" aria-label={text[locale].tabsLabel}>
{tabs.map((tab) => (
<button
key={tab.id}
id={`asset-tab-${tab.id}`}
role="tab"
type="button"
aria-selected={activeTab === tab.id}
aria-controls={`asset-panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
className={activeTab === tab.id ? "selected" : undefined}
onClick={() => selectTab(tab.id)}
onKeyDown={(event) => {
if (event.key === "ArrowRight") { event.preventDefault(); moveTab(1); }
if (event.key === "ArrowLeft") { event.preventDefault(); moveTab(-1); }
}}
>
{tab.label}
</button>
))}
</div>
<div id={`asset-panel-${activeTab}`} role="tabpanel" aria-labelledby={`asset-tab-${activeTab}`}>
{activeTab === "ai" && <>
<ProfileCatalog
locale={locale}
profiles={profiles}
Expand All @@ -1999,7 +2053,13 @@ export function AssetsPage({
usage={usage.profiles}
onDelete={(id) => onDelete("profile", id)}
/>
<PersonaCatalog builds={builds} onRefresh={legacy.onRefresh} />
<LegacyAssetsPage {...legacy} locale={locale} loading={loading} onDelete={onDelete} usage={usage} activeTab="ai" />
</>}
{activeTab === "test-design" && <>
<PersonaCatalog builds={builds} onRefresh={legacy.onRefresh} locale={locale} />
<LegacyAssetsPage {...legacy} locale={locale} loading={loading} onDelete={onDelete} usage={usage} activeTab="test-design" />
</>}
{activeTab === "run-setup" && <>
<EnvironmentCatalog
locale={locale}
executionEnvironments={executionEnvironments}
Expand All @@ -2009,7 +2069,9 @@ export function AssetsPage({
onDelete={onDelete}
usage={usage}
/>
<LegacyAssetsPage {...legacy} locale={locale} loading={loading} onDelete={onDelete} usage={usage} />
<LegacyAssetsPage {...legacy} locale={locale} loading={loading} onDelete={onDelete} usage={usage} activeTab="run-setup" />
</>}
</div>
</>
);
}
19 changes: 18 additions & 1 deletion frontend/src/locales/languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@
"savedHint": "Your draft is saved locally. Saving creates a new version."
},
"assetsText": {
"tabsLabel": "Asset categories",
"tabAI": "AI configuration",
"tabTestDesign": "Test design",
"tabRunSetup": "Run setup",
"personas": "Personas",
"persona": "Persona",
"personaDescription": "Define user characteristics, goals, and constraints to validate real user journeys.",
"emptyPersonas": "No personas yet.",
"locale": "Locale",
"timezone": "Timezone",
"goals": "Goals",
"goalsHint": "One per line",
"constraints": "Constraints",
"constraintsHint": "One per line",
"activityContext": "Activity windows / context",
"activityContextHint": "JSON",
"invalidPersonaJson": "Activity windows and context must be valid JSON",
"profiles": "AI model profiles",
"templates": "Manager prompt templates",
"tests": "Target-AI fixed test cases",
Expand Down Expand Up @@ -110,7 +127,7 @@
},
"shell": {
"dashboard": "Monitor task health, recent activity, and operational events at a glance.",
"assets": "Manage the reusable environments, runners, templates, test cases, and workflows used by tasks.",
"assets": "Manage reusable assets for AI configuration, test design, and run setup.",
"builds": "Combine reusable assets into tasks, then run or test them.",
"runs": "Inspect active and completed runs, their evidence, and approval decisions.",
"improvements": "Review run feedback, proposed improvements, and their outcomes over time.",
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/locales/languages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@
"savedHint": "下書きはローカルに保存されています。保存すると新しいバージョンが作成されます。"
},
"assetsText": {
"tabsLabel": "アセットのカテゴリー",
"tabAI": "AI 構成",
"tabTestDesign": "テスト設計",
"tabRunSetup": "実行設定",
"personas": "ペルソナ",
"persona": "ペルソナ",
"personaDescription": "ユーザーの特性、目標、制約を定義し、実際のユーザージャーニーを検証します。",
"emptyPersonas": "ペルソナはまだありません。",
"locale": "ロケール",
"timezone": "タイムゾーン",
"goals": "目標",
"goalsHint": "1 行に 1 つ",
"constraints": "制約",
"constraintsHint": "1 行に 1 つ",
"activityContext": "アクティビティ時間 / コンテキスト",
"activityContextHint": "JSON",
"invalidPersonaJson": "アクティビティ時間とコンテキストは有効な JSON である必要があります。",
"profiles": "AIモデルプロファイル",
"templates": "管理者プロンプトテンプレート",
"tests": "対象AI固定テストケース",
Expand Down Expand Up @@ -104,7 +121,7 @@
},
"shell": {
"dashboard": "評価の運用状態、最近のアクティビティ、運用イベントを一目で確認します。",
"assets": "評価で使う再利用可能な環境、ランナー、テンプレート、テストケース、ワークフローを管理します。",
"assets": "AI 構成、テスト設計、実行設定のための再利用可能なアセットを管理します。",
"builds": "再利用可能なアセットをタスクとして組み合わせ、実行またはテストします。",
"runs": "進行中・完了済みの実行、証跡、承認判断を確認します。",
"improvements": "実行フィードバック、改善提案、時間に応じた成果を確認します。",
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/locales/languages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@
"savedHint": "초안이 로컬에 저장되어 있습니다. 저장하면 새 버전이 생성됩니다."
},
"assetsText": {
"tabsLabel": "자산 카테고리",
"tabAI": "AI 구성",
"tabTestDesign": "테스트 설계",
"tabRunSetup": "실행 설정",
"personas": "페르소나",
"persona": "페르소나",
"personaDescription": "사용자 특성, 목표, 제약 조건을 정의해 실제 사용자 흐름을 검증합니다.",
"emptyPersonas": "아직 페르소나가 없습니다.",
"locale": "로케일",
"timezone": "시간대",
"goals": "목표",
"goalsHint": "한 줄에 하나씩",
"constraints": "제약 조건",
"constraintsHint": "한 줄에 하나씩",
"activityContext": "활동 시간 / 컨텍스트",
"activityContextHint": "JSON",
"invalidPersonaJson": "활동 시간과 컨텍스트는 유효한 JSON이어야 합니다.",
"profiles": "AI 모델 프로필",
"templates": "관리자 프롬프트 템플릿",
"tests": "대상 AI 고정 테스트 케이스",
Expand Down Expand Up @@ -109,7 +126,7 @@
},
"shell": {
"dashboard": "평가 운영 상태, 최근 활동, 운영 이벤트를 한눈에 확인합니다.",
"assets": "평가에 사용하는 재사용 환경, 러너, 템플릿, 테스트 케이스, 워크플로를 관리합니다.",
"assets": "AI 구성, 테스트 설계, 실행 설정을 위한 재사용 자산을 관리합니다.",
"builds": "재사용 자산을 태스크로 조합한 뒤 실행하거나 테스트합니다.",
"runs": "진행 중이거나 완료된 실행, 근거, 승인 결정을 확인합니다.",
"improvements": "실행 피드백, 개선 제안, 시간에 따른 결과를 검토합니다.",
Expand Down
Loading
Loading