Files
open-design/src/components/EntryView.tsx
T
pftom 32f5f857d2 feat(providers): support OpenAI-compatible / Azure / Google Gemini endpoints (closes #16)
The hosted-API BYOK fallback was hard-wired to api.anthropic.com. Issue
#16 asks for Azure (Microsoft OpenAI hosting Anthropic-style models) but
the same plumbing unlocks every common BYOK target — OpenRouter,
LiteLLM, DeepSeek, Groq, Together, Mistral, plus Google Gemini direct.

Provider model:
- New `provider: 'anthropic' | 'openai' | 'azure' | 'google'` discriminator
  on AppConfig (defaults to 'anthropic' so existing localStorage
  configs migrate seamlessly).
- src/providers/model.ts routes to one of four streaming clients:
  anthropic.ts (existing SDK path), openai.ts (new SSE pump shared with
  azure.ts), azure.ts (deployment URL + api-key header), google.ts
  (Generative Language streamGenerateContent).
- src/providers/presets.ts ships per-provider defaults (baseUrl, model
  suggestions, api-key placeholder, Azure api-version flag) so the UI
  can stay declarative.

UI:
- SettingsDialog now shows a provider picker on the Hosted-API tab and
  surfaces an Azure-only api-version field. Provider switches preserve
  any non-empty user values.
- EntryView / AvatarMenu env meta line shows the active provider label.
- en + zh-CN locales updated; README + README.zh-CN document every
  provider, with explicit guidance to reach AWS Bedrock / GCP Vertex
  Anthropic models via a server-side LiteLLM proxy (signing belongs on
  the server, not the browser).

Why an OpenAI-compatible adapter rather than a native Bedrock/Vertex
client: AWS SigV4 and GCP service-account JWTs aren't safe to do from a
browser holding long-lived BYOK credentials. LiteLLM (or any
Anthropic/OpenAI-compatible proxy) sidesteps that and is the same path
lobe-chat uses for Bedrock/Vertex.
2026-04-29 07:44:00 +08:00

344 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import { providerLabel } from '../providers/presets';
import type {
AgentInfo,
AppConfig,
DesignSystemSummary,
Project,
ProjectKind,
ProjectMetadata,
ProjectTemplate,
SkillSummary,
} from '../types';
import { DesignsTab } from './DesignsTab';
import { DesignSystemPreviewModal } from './DesignSystemPreviewModal';
import { DesignSystemsTab } from './DesignSystemsTab';
import { ExamplesTab } from './ExamplesTab';
import { Icon } from './Icon';
import { LanguageMenu } from './LanguageMenu';
import { CenteredLoader } from './Loading';
import { NewProjectPanel, type CreateInput } from './NewProjectPanel';
type TopTab = 'designs' | 'examples' | 'design-systems';
interface Props {
skills: SkillSummary[];
designSystems: DesignSystemSummary[];
projects: Project[];
templates: ProjectTemplate[];
defaultDesignSystemId: string | null;
config: AppConfig;
agents: AgentInfo[];
loading?: boolean;
onCreateProject: (input: CreateInput & { pendingPrompt?: string }) => void;
onOpenProject: (id: string) => void;
onDeleteProject: (id: string) => void;
onChangeDefaultDesignSystem: (id: string) => void;
onOpenSettings: () => void;
}
const SIDEBAR_MIN = 320;
const SIDEBAR_MAX = 560;
const SIDEBAR_DEFAULT = 380;
const SIDEBAR_STORAGE_KEY = 'od-entry-sidebar-width';
function loadSidebarWidth(): number {
try {
const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (!raw) return SIDEBAR_DEFAULT;
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return SIDEBAR_DEFAULT;
return Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, n));
} catch {
return SIDEBAR_DEFAULT;
}
}
export function EntryView({
skills,
designSystems,
projects,
templates,
defaultDesignSystemId,
config,
agents,
loading = false,
onCreateProject,
onOpenProject,
onDeleteProject,
onChangeDefaultDesignSystem,
onOpenSettings,
}: Props) {
const t = useT();
const [topTab, setTopTab] = useState<TopTab>('designs');
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
const [sidebarWidth, setSidebarWidth] = useState<number>(() => loadSidebarWidth());
const [resizing, setResizing] = useState(false);
const currentAgent = useMemo(
() => agents.find((a) => a.id === config.agentId) ?? null,
[agents, config.agentId],
);
const envMetaLine = useMemo(() => {
if (config.mode === 'api') {
const provider = providerLabel(config.provider);
try {
return `${provider} · ${config.model} · ${new URL(config.baseUrl).host}`;
} catch {
return `${provider} · ${config.model}`;
}
}
return currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('settings.noAgentSelected');
}, [config.mode, config.model, config.baseUrl, config.provider, currentAgent, t]);
// 'Use this prompt' on an example card is a fast path — skip the form and
// create the project immediately with sane defaults derived from the skill,
// seeding the chat composer with the example prompt via pendingPrompt.
function usePromptFromSkill(skill: SkillSummary) {
onCreateProject({
name: skill.name,
skillId: skill.id,
designSystemId: null,
metadata: metadataForSkill(skill),
pendingPrompt: skill.examplePrompt || skill.description,
});
}
function previewDesignSystem(id: string) {
setPreviewSystemId(id);
}
const previewSystem = useMemo(
() => (previewSystemId ? designSystems.find((d) => d.id === previewSystemId) ?? null : null),
[designSystems, previewSystemId],
);
function handleCreate(input: CreateInput) {
onCreateProject(input);
}
const startWidthRef = useRef(0);
const startXRef = useRef(0);
useEffect(() => {
if (!resizing) return;
function onMove(e: MouseEvent) {
const dx = e.clientX - startXRef.current;
const next = Math.max(
SIDEBAR_MIN,
Math.min(SIDEBAR_MAX, startWidthRef.current + dx),
);
setSidebarWidth(next);
}
function onUp() {
setResizing(false);
}
document.body.classList.add('entry-resizing');
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
document.body.classList.remove('entry-resizing');
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [resizing]);
useEffect(() => {
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
} catch {
/* ignore */
}
}, [sidebarWidth]);
return (
<div
className="entry"
style={{ gridTemplateColumns: `${sidebarWidth}px 1fr` }}
>
<aside className="entry-side" style={{ width: sidebarWidth }}>
<div className="entry-brand">
<span className="entry-brand-mark" aria-hidden>
<img src="/logo.svg" alt="" className="brand-mark-img" draggable={false} />
</span>
<div className="entry-brand-text">
<div className="entry-brand-title-row">
<span className="entry-brand-title">{t('app.brand')}</span>
<span className="entry-brand-pill">{t('app.brandPill')}</span>
</div>
<div className="entry-brand-subtitle">{t('app.brandSubtitle')}</div>
</div>
</div>
<NewProjectPanel
skills={skills}
designSystems={designSystems}
defaultDesignSystemId={defaultDesignSystemId}
templates={templates}
onCreate={handleCreate}
loading={loading}
/>
<div className="entry-side-foot">
<button
type="button"
className="foot-pill"
onClick={onOpenSettings}
title={t('settings.envConfigure')}
>
<Icon name="settings" size={12} />
<span>
{config.mode === 'daemon'
? t('settings.localCli')
: t('settings.anthropicApi')}
</span>
<span style={{ color: 'var(--text-faint)' }}>·</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>
{envMetaLine}
</span>
</button>
<LanguageMenu />
</div>
<button
type="button"
aria-label={t('entry.resizeAria')}
className={`entry-side-resizer${resizing ? ' dragging' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
startWidthRef.current = sidebarWidth;
startXRef.current = e.clientX;
setResizing(true);
}}
/>
</aside>
<main className="entry-main">
<div className="entry-header">
<div className="entry-tabs" role="tablist">
<TopTabButton current={topTab} value="designs" label={t('entry.tabDesigns')} onClick={setTopTab} />
<TopTabButton current={topTab} value="examples" label={t('entry.tabExamples')} onClick={setTopTab} />
<TopTabButton
current={topTab}
value="design-systems"
label={t('entry.tabDesignSystems')}
onClick={setTopTab}
/>
</div>
<div className="entry-header-right">
{/* Avatar settings live next to tabs to mirror the project view. */}
<button
type="button"
className="avatar-btn"
onClick={onOpenSettings}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
>
<img
src="/avatar.png"
alt=""
aria-hidden
draggable={false}
className="avatar-btn-photo"
/>
</button>
</div>
</div>
<div className="entry-tab-content">
{loading ? (
<CenteredLoader label={t('entry.loadingWorkspace')} />
) : (
<>
{topTab === 'designs' ? (
<DesignsTab
projects={projects}
skills={skills}
designSystems={designSystems}
onOpen={onOpenProject}
onDelete={onDeleteProject}
/>
) : null}
{topTab === 'examples' ? (
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
) : null}
{topTab === 'design-systems' ? (
<DesignSystemsTab
systems={designSystems}
selectedId={defaultDesignSystemId}
onSelect={onChangeDefaultDesignSystem}
onPreview={previewDesignSystem}
/>
) : null}
</>
)}
</div>
</main>
{previewSystem ? (
<DesignSystemPreviewModal
system={previewSystem}
onClose={() => setPreviewSystemId(null)}
/>
) : null}
</div>
);
}
function TopTabButton({
current,
value,
label,
onClick,
}: {
current: TopTab;
value: TopTab;
label: string;
onClick: (v: TopTab) => void;
}) {
return (
<button
role="tab"
aria-selected={current === value}
className={`entry-tab ${current === value ? 'active' : ''}`}
onClick={() => onClick(value)}
>
{label}
</button>
);
}
// Map a skill's declared mode to project metadata. Falls back to the same
// defaults the new-project form would apply (high-fidelity prototype, no
// speaker notes on decks, no template animations) so 'Use this prompt'
// produces a project indistinguishable from one created via the form. Per-
// skill hints in SKILL.md frontmatter (od.fidelity, od.speaker_notes,
// od.animations) override the defaults so each example reproduces the
// shipped example.html — e.g. wireframe-sketch declares fidelity:wireframe.
function metadataForSkill(skill: SkillSummary): ProjectMetadata {
const kind = kindForSkill(skill);
if (kind === 'prototype') {
return { kind, fidelity: skill.fidelity ?? 'high-fidelity' };
}
if (kind === 'deck') {
return {
kind,
speakerNotes:
typeof skill.speakerNotes === 'boolean' ? skill.speakerNotes : false,
};
}
if (kind === 'template') {
return {
kind,
animations:
typeof skill.animations === 'boolean' ? skill.animations : false,
};
}
return { kind: 'other' };
}
function kindForSkill(skill: SkillSummary): ProjectKind {
if (skill.mode === 'deck') return 'deck';
if (skill.mode === 'prototype') return 'prototype';
if (skill.mode === 'template') return 'template';
return 'other';
}