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
4 changes: 4 additions & 0 deletions src/backend/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function useBackendModels() {
const [models, setModels] = useState<ModelConfig[]>([]);
const [settings, setSettings] = useState<GeneralSettings | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [connected, setConnected] = useState(false);
const refresh = useCallback(async () => {
try {
Expand All @@ -98,6 +99,8 @@ export function useBackendModels() {
} catch (failure) {
setConnected(false);
setError(errorMessage(failure));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
Expand Down Expand Up @@ -155,6 +158,7 @@ export function useBackendModels() {
settings,
error,
connected,
loading,
refresh,
save,
saveMany,
Expand Down
21 changes: 20 additions & 1 deletion src/canvas/CanvasWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ import { ProjectNameDialog } from "./ProjectNameDialog";
import { ProjectSelector } from "./ProjectSelector";
import { referencePresentation, referenceSnapshot } from "./referenceEvidence";
import "./settings.css";
import { modelSetupMissing, dismissModelSetup, shouldShowModelSetup } from "./ModelSetup";
import { WelcomeScreen } from "./WelcomeScreen";
import { SettingsDialog } from "./SettingsDialog";
import { alignNodes, type Alignment } from "./snapping";
import { StackEditor } from "./StackEditor";
Expand Down Expand Up @@ -675,6 +677,19 @@ function Workspace() {
const [settingsCategory, setSettingsCategory] = useState<
"general" | "models"
>("general");
const [welcomeOpen, setWelcomeOpen] = useState(false);
const setupChecked = useRef(false);
useEffect(() => {
if (setupChecked.current || !backendCatalog.connected) return;
setupChecked.current = true;
const preview = new URLSearchParams(window.location.search).get("setup");
if (preview === "models") {
setSettingsCategory("models");
setSettingsOpen(true);
} else if (preview === "welcome" || (shouldShowModelSetup() && modelSetupMissing(backendCatalog.models).length > 0)) {
setWelcomeOpen(true);
}
}, [backendCatalog.connected, backendCatalog.models]);
const theme = useTheme();
const serviceRuns = useRef(new Map<string, ServiceRun>());
const [activeGenerations, setActiveGenerations] = useState<
Expand Down Expand Up @@ -6565,12 +6580,16 @@ function Workspace() {
onClose={closeEditor}
/>
)}
{welcomeOpen && <WelcomeScreen
catalog={backendCatalog}
onSkip={() => { dismissModelSetup(); setWelcomeOpen(false); }}
/>}
{settingsOpen && (
<SettingsDialog
catalog={backendCatalog}
theme={theme}
initialCategory={settingsCategory}
onClose={() => setSettingsOpen(false)}
onClose={() => { dismissModelSetup(); setSettingsOpen(false); }}
/>
)}
{help && (
Expand Down
34 changes: 32 additions & 2 deletions src/canvas/ModelSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ModelSetup, modelSetupMissing, type SetupKind } from "./ModelSetup";
import { ModelLogo } from "./ModelLogo";
import { providerLabel } from "./providerPresets";
import { useState } from "react";
Expand All @@ -9,9 +10,9 @@ import { ModelServiceForm, type SettingsModelKind } from "./ModelServiceForm";
import { CanvasIcon } from "./CanvasIcon";
import "./model-settings.css";

export function ModelSettings({ catalog }: { catalog: BackendModels }) {
export function ModelSettings({ catalog, onClose, guided = false }: { catalog: BackendModels; onClose: () => void; guided?: boolean }) {
const t = useT();
const [category, setCategory] = useState<SettingsModelKind>("agent");
const [category, setCategory] = useState<SettingsModelKind | null>(null);
const [editing, setEditing] = useState<{
kind: SettingsModelKind;
model?: ModelConfig;
Expand Down Expand Up @@ -43,8 +44,28 @@ export function ModelSettings({ catalog }: { catalog: BackendModels }) {
models.findIndex((item) => item.providerId === model.providerId) ===
index,
);
const [skipped, setSkipped] = useState<SetupKind[]>([]);
const missing = modelSetupMissing(catalog.models);
const pending = missing.filter(kind => !skipped.includes(kind));
function skipItem(kind: SetupKind) {
setSkipped(current => current.includes(kind) ? current : [...current, kind]);
const next = pending.find(item => item !== kind);
if (!next) { onClose(); return; }
setEditing(null);
setCategory(next ?? null);
setDeleting(null);
setError("");
}
const currentMissing = category !== null && missing.includes(category);
if (category === null) return <div className="cw-model-settings">
<ModelSetup catalog={catalog} onClose={onClose} onConfigure={setCategory} skipped={skipped} onSkipItem={guided ? skipItem : undefined} />
</div>;
return (
<div className="cw-model-settings">
{guided && <div className="cw-model-guide-progress" role="status">
<span>{t("modelSetup.count", { count: 4 - missing.length, total: 4 })}</span>
<p>{t(editing ? "modelSetup.guideSave" : currentMissing ? "modelSetup.guideAdd" : "modelSetup.guideNext")}</p>
</div>}
{!catalog.connected && (
<div className="cw-settings-notice" role="alert">
<span>{catalog.error || t("models.readFailed")}</span>
Expand All @@ -71,6 +92,7 @@ export function ModelSettings({ catalog }: { catalog: BackendModels }) {
className="cw-model-categories"
aria-label={t("models.categories")}
>
<button type="button" onClick={() => setCategory(null)}>{t("modelSetup.overview")}</button>
{MODEL_KINDS.map((item) => (
<button
key={item.id}
Expand Down Expand Up @@ -211,6 +233,14 @@ export function ModelSettings({ catalog }: { catalog: BackendModels }) {
</div>
</>
))}
{guided && !editing && <footer className="cw-model-guide-footer">
{currentMissing && <button className="cw-settings-button cw-settings-button-quiet" onClick={() => skipItem(category)}>{t("modelSetup.skipItem")}</button>}
<button className="cw-settings-button cw-settings-button-primary" disabled={!catalog.connected} onClick={() => {
if (currentMissing) setEditing({ kind: category });
else if (pending[0]) { setCategory(pending[0]); setDeleting(null); setError(""); }
else onClose();
}}>{currentMissing ? t("models.services.add") : pending[0] ? t("modelSetup.nextModel", { model: t(`modelSetup.${pending[0]}`) }) : t("modelSetup.done")}</button>
</footer>}
</div>
);
}
67 changes: 67 additions & 0 deletions src/canvas/ModelSetup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { BackendModels } from "../backend/models";
import { isGenerationModel, type ModelConfig } from "../backend/rules";
import { useI18n } from "../i18n/I18nProvider";
import { CanvasIcon } from "./CanvasIcon";
import "./model-setup.css";

const kinds = ["agent", "image", "video", "audio"] as const;
export type SetupKind = typeof kinds[number];
const preference = "opendirector.modelSetup.v1";
let dismissed = false;
export function dismissModelSetup() {
dismissed = true;
try { localStorage.setItem(preference, "dismissed"); } catch { /* Session fallback. */ }
}
export function shouldShowModelSetup() {
if (dismissed) return false;
try { return !localStorage.getItem(preference); } catch { return true; }
}
export function modelSetupMissing(models: ModelConfig[]) {
return kinds.filter(kind => !models.some(model => model.kind === kind && isGenerationModel(model)));
}

export function ModelSetup({ catalog, onClose, onConfigure, skipped = [], onSkipItem }: {
skipped?: SetupKind[];
onSkipItem?: (kind: SetupKind) => void;
catalog: BackendModels;
onClose: () => void;
onConfigure: (kind: SetupKind) => void;
}) {
const { t, locale, setLocale } = useI18n();
const missing = modelSetupMissing(catalog.models);
const next = missing.find(kind => !skipped.includes(kind));
return <div className="cw-model-setup">
<div className="cw-model-setup-language">
<label htmlFor="model-setup-language">{t("modelSetup.language")}</label>
<select id="model-setup-language" value={locale} onChange={event => setLocale(event.target.value as "zh-CN" | "en-US")}>
<option value="zh-CN">{t("common.chinese")}</option><option value="en-US">{t("common.english")}</option>
</select>
</div>
<div className="cw-model-setup-intro">
<p>{t("modelSetup.hint")}</p>
</div>
{catalog.loading ? <p role="status">{t("modelSetup.loading")}</p> : !catalog.connected ?
<div className="cw-settings-notice" role="alert"><span>{catalog.error || t("modelSetup.failed")}</span>
<button className="cw-settings-button" onClick={() => void catalog.refresh()}>{t("modelSetup.retry")}</button>
</div> : <>
<p className="cw-model-setup-progress" role="status">{t("modelSetup.count", { count: kinds.length - missing.length, total: kinds.length })}</p>
<div className="cw-model-setup-list">{kinds.map(kind => {
const configured = !missing.includes(kind);
return <div className="cw-model-setup-row" key={kind}>
<span className="cw-model-setup-icon"><CanvasIcon name={kind === "agent" ? "model" : kind === "image" ? "image" : kind === "audio" ? "audio" : "video"} size={20} /></span>
<div className="cw-model-setup-copy"><strong>{t(`modelSetup.${kind}`)}</strong><p>{t(`modelSetup.${kind}Hint`)}</p></div>
<span className={`cw-model-setup-status${configured ? " is-configured" : ""}`}>{configured && <span aria-hidden="true">✓ </span>}{t(configured ? "modelSetup.configured" : skipped.includes(kind) ? "modelSetup.deferred" : "modelSetup.missing")}</span>
<button className="cw-settings-button" onClick={() => onConfigure(kind)}>{t(configured ? "modelSetup.manage" : "modelSetup.configure")}</button>
</div>;
})}</div>

</>}
<footer className="cw-model-setup-footer">
<p>{t("modelSetup.external")}</p>
<div>{next && onSkipItem && <button className="cw-settings-button cw-settings-button-quiet" onClick={() => onSkipItem(next)}>{t("modelSetup.skipItem")}</button>}
<button className="cw-settings-button cw-settings-button-primary" disabled={!catalog.connected} onClick={() => next ? onConfigure(next) : onClose()}>
{t(next ? `modelSetup.${next}Action` : "modelSetup.done")}
</button></div>
</footer>
</div>;
}
4 changes: 2 additions & 2 deletions src/canvas/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { useI18n, useT } from "../i18n/I18nProvider";
import type { Locale } from "../i18n/types";

const categories = [
{ id: "general", labelKey: "settings.general", icon: "settings" },
{ id: "models", labelKey: "settings.models", icon: "model" },
{ id: "general", labelKey: "settings.general", icon: "settings" },
] as const;

function GeneralSettings({
Expand Down Expand Up @@ -230,7 +230,7 @@ export function SettingsDialog({
{category === "general" ? (
<GeneralSettings catalog={catalog} theme={theme} />
) : (
<ModelSettings catalog={catalog} />
<ModelSettings catalog={catalog} onClose={onClose} />
)}
</div>
</section>
Expand Down
84 changes: 84 additions & 0 deletions src/canvas/WelcomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useLayoutEffect, useRef, useState } from "react";
import type { BackendModels } from "../backend/models";
import { ModelSettings } from "./ModelSettings";
import "./settings.css";
import { useI18n } from "../i18n/I18nProvider";
import animatedLogo from "../assets/open_director_logo_anim.gif";
import staticLogo from "../assets/open_director_logo.png";
import "./welcome-screen.css";

export function WelcomeScreen({ catalog, onSkip }: { catalog: BackendModels; onSkip: () => void }) {
const { t, locale, setLocale } = useI18n();
const dialog = useRef<HTMLDialogElement>(null);
const [configuring, setConfiguring] = useState(false);
const [completed, setCompleted] = useState(false);
function finishSetup() { setCompleted(true); }
const previous = useRef<DOMRect[]>([]);
const panel = useRef<HTMLElement>(null);
const transitioning = useRef(false);
async function beginSetup() {
if (transitioning.current) return;
transitioning.current = true;
const actions = dialog.current?.querySelector<HTMLElement>(".cw-welcome-intro-actions");
if (actions && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
await actions.animate([{ opacity: 1 }, { opacity: 0, transform: "translateY(8px)" }], { duration: 160, fill: "forwards" }).finished;
}
previous.current = Array.from(dialog.current!.querySelectorAll("[data-welcome-shared]")).map(element => element.getBoundingClientRect());
setConfiguring(true);
}
useLayoutEffect(() => {
if (!configuring) return;
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
dialog.current!.querySelectorAll<HTMLElement>("[data-welcome-shared]").forEach((element, index) => {
const before = previous.current[index];
const after = element.getBoundingClientRect();
if (!before || !after.width) return;
element.animate([
{ transformOrigin: "top left", transform: `translate(${before.left - after.left}px, ${before.top - after.top}px) scale(${before.width / after.width}, ${before.height / after.height})` },
{ transformOrigin: "top left", transform: "none" },
], { duration: 760, easing: "cubic-bezier(0.22, 1, 0.36, 1)" });
});
}
panel.current?.focus({ preventScroll: true });
}, [configuring]);
useLayoutEffect(() => {
const element = dialog.current!;
element.showModal();
return () => element.close();
}, []);
return <dialog ref={dialog} className={`cw-welcome${configuring ? " is-configuring" : ""}`} aria-labelledby="welcome-title" onCancel={event => { event.preventDefault(); if (configuring && !completed) finishSetup(); else onSkip(); }}>
<div className="cw-welcome-language">
<label htmlFor="welcome-language">{t("modelSetup.language")}</label>
<select id="welcome-language" value={locale} onChange={event => setLocale(event.target.value as "zh-CN" | "en-US")}>
<option value="zh-CN">{t("common.chinese")}</option><option value="en-US">{t("common.english")}</option>
</select>
</div>
<main className="cw-welcome-content">
<header className="cw-welcome-hero">
<div data-welcome-shared className="cw-welcome-logo" aria-hidden="true">
<img className="cw-welcome-animation" src={animatedLogo} alt="" />
<img className="cw-welcome-static" src={staticLogo} alt="" />
</div>
<h1 data-welcome-shared id="welcome-title" tabIndex={-1} autoFocus>{t("modelSetup.welcomeTitle")}</h1>
<p data-welcome-shared className="cw-welcome-description">{t("modelSetup.welcomeDescription")}</p>
</header>
{!configuring && <div className="cw-welcome-intro-actions">
<div className="cw-welcome-actions">
<button className="cw-welcome-primary" onClick={beginSetup}>{t("modelSetup.welcomeConfigure")}</button>
<button className="cw-welcome-secondary" onClick={onSkip}>{t("modelSetup.welcomeSkip")}</button>
</div>
<p className="cw-welcome-note">{t("modelSetup.welcomeNote")}</p>
</div>}
{configuring && !completed && <section ref={panel} tabIndex={-1} aria-label={t("settings.models")} className="cw-settings-dialog cw-welcome-setup">
<div className="cw-settings-body"><ModelSettings catalog={catalog} onClose={finishSetup} guided /></div>
</section>}
{completed && <section className="cw-welcome-complete" aria-labelledby="setup-complete-title">
<span className="cw-welcome-complete-check" aria-hidden="true">✓</span>
<h2 id="setup-complete-title">{t("modelSetup.finishedTitle")}</h2>
<p>{t("modelSetup.finishedHint")}</p>
<button autoFocus className="cw-welcome-primary" onClick={onSkip}>{t("modelSetup.done")}</button>
</section>}
</main>
<footer className="cw-welcome-footer">{configuring && !completed && <button className="cw-welcome-skip-all" onClick={finishSetup}>{t("modelSetup.skipAll")}</button>}<span>OpenDirector</span></footer>
</dialog>;
}
28 changes: 28 additions & 0 deletions src/canvas/model-setup.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.cw-model-setup { width: 100%; display: flex; flex-direction: column; position: relative; }
.cw-model-setup-language { display: flex; justify-content: flex-end; align-items: center; gap: 8px; color: var(--settings-muted); font-size: 12px; }
.cw-model-setup-language select { width: auto; min-height: 30px; }
.cw-model-setup-intro { padding: 12px 0 16px; }
.cw-model-setup-intro p { margin: 0; color: var(--settings-muted); line-height: 1.6; font-size: 13px; }
.cw-model-setup-progress { font-size: 12px; color: var(--settings-muted); margin: 0 0 12px; }
.cw-model-setup-list { overflow: hidden; border: 1px solid var(--settings-border); border-radius: 12px; background: var(--settings-surface); }
.cw-model-setup-row { display: flex; gap: 12px; align-items: center; padding: 15px 16px; }
.cw-model-setup-row + .cw-model-setup-row { border-top: 1px solid var(--settings-border); }
.cw-model-setup-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; background: var(--settings-hover); color: var(--settings-muted); flex-shrink: 0; }
.cw-model-setup-copy { flex: 1; min-width: 0; }
.cw-model-setup-copy strong { font-size: 14px; font-weight: 500; }
.cw-model-setup-copy p { color: var(--settings-muted); margin: 4px 0 0; font-size: 12px; line-height: 1.5; }
.cw-model-setup-status { font-size: 12px; color: var(--settings-muted); flex-shrink: 0; }
.cw-model-setup-status.is-configured { color: #75d69a; }
:root[data-theme="light"] .cw-model-setup-status.is-configured { color: #237544; }
.cw-model-setup-footer { display: flex; align-items: flex-end; gap: 16px; padding-top: 20px; }
.cw-model-setup-footer > p { flex: 1; font-size: 12px; color: var(--settings-muted); line-height: 1.7; margin: 0; }
.cw-model-setup-footer > div { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.cw-settings-body:has(.cw-model-setup) { overflow-y: auto; }
.cw-settings-body:has(.cw-model-setup) > .cw-model-settings { flex-shrink: 0; }
@media (max-width: 600px) {
.cw-model-setup-row { flex-wrap: wrap; gap: 10px; padding: 12px; }
.cw-model-setup-copy { flex-basis: calc(100% - 50px); }
.cw-model-setup-status { margin-left: 44px; margin-right: auto; }
.cw-model-setup-footer { flex-direction: column; align-items: stretch; }
.cw-model-setup-footer > div { justify-content: flex-end; flex-wrap: wrap; }
}
Loading