Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proma/electron",
"version": "0.17.15",
"version": "0.17.16",
"description": "Proma next gen ai software with general agents - Electron App",
"main": "dist/main.cjs",
"author": {
Expand Down
64 changes: 51 additions & 13 deletions apps/electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react'
import { useAtom, useStore } from 'jotai'
import { AppShell } from './components/app-shell/AppShell'
import { InitialModelSetupView } from './components/onboarding/InitialModelSetupView'
import { OnboardingView } from './components/onboarding/OnboardingView'
import { TutorialBanner } from './components/tutorial/TutorialBanner'
import { EnvironmentCheckDialog } from './components/environment/EnvironmentCheckDialog'
Expand All @@ -17,6 +18,7 @@ import { onboardingReplayRequestedAtom } from './atoms/onboarding'
import { settingsOpenAtom, settingsTabAtom } from './atoms/settings-tab'
import { tabsAtom, activeTabIdAtom, openTab, TUTORIAL_TAB_ID } from './atoms/tab-atoms'
import { hasCompletedCurrentOnboarding } from '../types'
import { requiresInitialModelSetup } from './lib/model-setup'
import hopperSeasideWhiteHouse from './assets/onboarding/hopper-seaside-white-house.png'
import promaMarkWhite from './assets/onboarding/proma-mark-white.svg'
import type { AppShellContextType } from './contexts/AppShellContext'
Expand All @@ -27,6 +29,8 @@ export default function App(): React.ReactElement {
const store = useStore()
const [isLoading, setIsLoading] = React.useState(true)
const [showOnboarding, setShowOnboarding] = React.useState(false)
const [showInitialModelSetup, setShowInitialModelSetup] = React.useState(false)
const [initialModelSetupCreatesWelcome, setInitialModelSetupCreatesWelcome] = React.useState(false)
const [onboardingReplayRequested, setOnboardingReplayRequested] = useAtom(onboardingReplayRequestedAtom)
const [isReplayingOnboarding, setIsReplayingOnboarding] = React.useState(false)
const isWindows = React.useMemo(() => detectIsWindows(), [])
Expand All @@ -37,9 +41,14 @@ export default function App(): React.ReactElement {
React.useEffect(() => {
const initialize = async () => {
try {
const settings = await window.electronAPI.getSettings()
const [settings, channels] = await Promise.all([
window.electronAPI.getSettings(),
window.electronAPI.listChannels(),
])
if (!hasCompletedCurrentOnboarding(settings)) {
setShowOnboarding(true)
} else if (requiresInitialModelSetup(channels)) {
setShowInitialModelSetup(true)
}
} catch (error) {
console.error('[App] 初始化失败:', error)
Expand All @@ -60,18 +69,7 @@ export default function App(): React.ReactElement {
setOnboardingReplayRequested(false)
}, [isLoading, onboardingReplayRequested, setOnboardingReplayRequested])

// 完成 onboarding 回调:创建欢迎对话,可选打开教程 Tab
const handleOnboardingComplete = async (openTutorial?: boolean) => {
const replayingOnboarding = isReplayingOnboarding
setShowOnboarding(false)
setIsReplayingOnboarding(false)

if (replayingOnboarding) {
store.set(settingsTabAtom, 'onboarding')
store.set(settingsOpenAtom, true)
return
}

const enterProma = async (openTutorial?: boolean): Promise<void> => {
if (openTutorial) {
const tabs = store.get(tabsAtom)
const result = openTab(tabs, { type: 'tutorial', sessionId: TUTORIAL_TAB_ID, title: 'Proma 使用教程' })
Expand Down Expand Up @@ -100,6 +98,45 @@ export default function App(): React.ReactElement {
}
}

// 完成 onboarding 后,首次没有保存过渠道的用户必须先走完设置页的创建表单。
const handleOnboardingComplete = async (openTutorial?: boolean): Promise<void> => {
const replayingOnboarding = isReplayingOnboarding
setIsReplayingOnboarding(false)

if (replayingOnboarding) {
setShowOnboarding(false)
store.set(settingsTabAtom, 'onboarding')
store.set(settingsOpenAtom, true)
return
}

try {
const [settings, channels] = await Promise.all([
window.electronAPI.getSettings(),
window.electronAPI.listChannels(),
])
if (requiresInitialModelSetup(channels)) {
setInitialModelSetupCreatesWelcome(true)
setShowInitialModelSetup(true)
setShowOnboarding(false)
return
}
} catch (error) {
// 无法读取渠道时不阻止用户进入应用,避免临时 IPC 错误造成无法启动。
console.error('[App] 检查首次模型配置失败:', error)
}

setShowOnboarding(false)
await enterProma(openTutorial)
}

const handleInitialModelSetupComplete = async (): Promise<void> => {
setShowInitialModelSetup(false)
if (!initialModelSetupCreatesWelcome) return
setInitialModelSetupCreatesWelcome(false)
await enterProma()
}

// 加载中状态
if (isLoading) {
return <StartupLoadingScreen />
Expand Down Expand Up @@ -140,6 +177,7 @@ export default function App(): React.ReactElement {
<FaqDialog />
<TutorialBanner />
<GlobalEnvironmentCheckDialog />
{showInitialModelSetup && <InitialModelSetupView onComplete={handleInitialModelSetupComplete} />}
</TooltipProvider>
)
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Channel } from '@proma/shared'
import { CheckCircle2 } from 'lucide-react'
import { ChannelForm } from '@/components/settings/ChannelForm'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'

interface InitialModelSetupViewProps {
onComplete: () => void
}

/**
* 首次使用时在已加载的主界面上覆盖渠道创建 Dialog。
* 这让用户能看到 Proma 已经可用,同时要求先保存一个渠道配置再开始交互。
*/
export function InitialModelSetupView({ onComplete }: InitialModelSetupViewProps): React.ReactElement {
const handleSaved = (channel?: Channel): void => {
if (channel) onComplete()
}

return (
<Dialog open onOpenChange={() => undefined}>
<DialogContent
hideClose
className="max-h-[calc(100vh-3rem)] max-w-5xl gap-0 overflow-y-auto p-0 sm:rounded-2xl"
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<div className="px-6 py-7 sm:px-8 sm:py-8">
<DialogHeader className="mb-7 max-w-2xl text-left">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-primary">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
<span>已进入 Proma</span>
</div>
<DialogTitle className="text-2xl font-semibold">添加模型渠道</DialogTitle>
<DialogDescription className="mt-2 leading-6">
完成这一步后即可开始使用 Proma;模型可稍后再添加。
</DialogDescription>
</DialogHeader>

<ChannelForm
channel={null}
initialSetup
onSaved={handleSaved}
onCancel={() => undefined}
/>
</div>
</DialogContent>
</Dialog>
)
}
93 changes: 82 additions & 11 deletions apps/electron/src/renderer/components/settings/ChannelForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ import {
resolveOpenAIResponsesUrl,
} from '@proma/core'
import { getProviderLogo } from '@/lib/model-logo'
import openaiModelLogo from '@/assets/models/official/openai.png'
import claudeModelLogo from '@/assets/models/official/claude.png'
import deepseekModelLogo from '@/assets/models/official/deepseek.png'
import moonshotModelLogo from '@/assets/models/official/moonshot.png'
import zhipuModelLogo from '@/assets/models/official/zhipu.png'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
AlertDialog,
Expand All @@ -75,6 +80,8 @@ import {
interface ChannelFormProps {
/** 编辑模式下传入已有渠道,创建模式传 null */
channel: Channel | null
/** 首次配置时隐藏返回操作,完成创建后才进入主工作区。 */
initialSetup?: boolean
onSaved: (channel?: Channel) => void
onCancel: () => void
}
Expand Down Expand Up @@ -198,7 +205,65 @@ function buildZhipuTeamSecret(secret: ZhipuTeamSecretForm): string {
/** auto-save 防抖延迟 */
const AUTO_SAVE_DELAY = 600

export function ChannelForm({ channel, onSaved, onCancel }: ChannelFormProps): React.ReactElement {
const OFFICIAL_MODEL_CHANNELS = [
{ provider: 'OpenAI', model: 'GPT 5.6 最新模型', logo: openaiModelLogo },
{ provider: 'Anthropic', model: 'Claude 最新模型', logo: claudeModelLogo },
{ provider: 'DeepSeek', model: 'DeepSeek V4', logo: deepseekModelLogo },
{ provider: 'Moonshot', model: 'Kimi K3', logo: moonshotModelLogo },
{ provider: '智谱', model: 'GLM-5.2', logo: zhipuModelLogo },
] as const

function openPromaCommercialDownload(): void {
void window.electronAPI.openExternal('https://proma.cool/download')
}

function OfficialCloudChannelNotice(): React.ReactElement {
return (
<SettingsSection
title="没有可用渠道?"
description="当前为 Proma 开源版,下载并于 Proma 商业版登录即可使用以下官方内置模型渠道,也保留第三方渠道配置。"
>
<div className="relative mt-5 border-2 border-foreground bg-card px-5 pb-5 pt-8 shadow-xl sm:px-6 sm:pb-6">
<div className="absolute -left-0.5 -top-3 bg-foreground px-3 py-1 text-[11px] font-semibold tracking-[0.16em] text-background">
Proma 商业版
</div>

<div>
<p className="text-sm font-semibold text-foreground">当前 Proma 官方 Agent 渠道</p>
<ul className="mt-5 grid grid-cols-1 gap-x-10 gap-y-4 text-sm text-muted-foreground sm:grid-cols-2 xl:grid-cols-3">
{OFFICIAL_MODEL_CHANNELS.map(({ provider, model, logo }) => (
<li key={provider} className="flex min-w-0 items-center gap-2.5">
<img src={logo} alt="" aria-hidden="true" className="h-5 w-5 shrink-0 rounded-md" />
<span className="whitespace-nowrap">{provider} · {model}</span>
</li>
))}
</ul>
</div>

<div className="mt-7 border-t border-foreground/20 pt-5">
<p className="text-sm leading-6 text-muted-foreground">
官方托管链路提供 Agent 协议兼容与模型健康监控;精选模型享受 Proma Cloud 优惠,并包含 WebSearch、GPT Image 2 等内嵌能力。
</p>
<Button
type="button"
variant="outline"
className="mt-4 gap-1.5 border-foreground bg-foreground text-background hover:bg-foreground/90 hover:text-background"
onClick={openPromaCommercialDownload}
>
<Download size={15} />
<span>下载 Proma 商业版</span>
</Button>
</div>

<p className="mt-5 border-t border-foreground/20 pt-3 text-xs text-muted-foreground">
可用模型、价格和权益会随时间调整,以商业版应用内当期展示为准。
</p>
</div>
</SettingsSection>
)
}

export function ChannelForm({ channel, initialSetup = false, onSaved, onCancel }: ChannelFormProps): React.ReactElement {
const isEdit = channel !== null

// 表单状态
Expand Down Expand Up @@ -783,9 +848,9 @@ export function ChannelForm({ channel, onSaved, onCancel }: ChannelFormProps): R
}
}

/** 创建渠道(仅新建模式) */
/** 创建渠道(仅新建模式)。首次 onboarding 可先保存空模型渠道,后续再补充模型。 */
const handleCreate = async (): Promise<void> => {
if (models.length === 0) {
if (!initialSetup && models.length === 0) {
toast.warning('尚未配置模型,建议先从供应商获取或手动添加', { id: 'no-models-warn' })
return
}
Expand Down Expand Up @@ -863,16 +928,22 @@ export function ChannelForm({ channel, onSaved, onCancel }: ChannelFormProps): R

return (
<div className="space-y-6">
{!isEdit && <OfficialCloudChannelNotice />}

{/* 标题栏 */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleBack}
>
<ArrowLeft size={18} />
</Button>
{!initialSetup && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleBack}
title="返回模型配置列表"
aria-label="返回模型配置列表"
>
<ArrowLeft size={18} />
</Button>
)}
<h3 className="text-lg font-medium text-foreground flex-1">
{isEdit ? '编辑模型配置' : '添加模型配置'}
</h3>
Expand Down
9 changes: 9 additions & 0 deletions apps/electron/src/renderer/lib/model-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Channel } from '@proma/shared'

/**
* 首次进入 Proma 前只要求用户至少保存过一个渠道。
* 已有用户即使暂时停用渠道,也不应因为升级被重新拦截。
*/
export function requiresInitialModelSetup(channels: readonly Channel[]): boolean {
return channels.length === 0
}