Skip to content

Commit b33d5ef

Browse files
ErlichLiuclaude
andauthored
feat(tutorial): switch to tutorial-v2.md with new-tab preview (#785)
* feat(tutorial): switch to tutorial-v2.md with new-tab preview - Point tutorial-service and electron-builder to tutorial-v2.md - Add 'tutorial' TabType with fixed TUTORIAL_TAB_ID constant - TabContent renders TutorialViewer in a ScrollArea for tutorial tabs - TutorialBanner opens tutorial New Tab instead of settings panel - OnboardingView opens tutorial New Tab on completion instead of Sheet popup - SettingsPanel "教程" nav item opens tutorial New Tab and closes settings - TabBar handleActivate handles tutorial tab without changing app mode Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tutorial): use __dirname for dev path, add tutorial-v2.md to git - Fix dev-mode path: __dirname (dist/) is stable across worktrees, app.getAppPath() ../../ breaks when worktree is outside the monorepo - Add tutorial/tutorial-v2.md to git so worktrees can access it Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tutorial): use __dirname for dev-mode tutorial path app.getAppPath() ../../ breaks in worktrees outside the monorepo root. __dirname (dist/) → ../../../tutorial/ is stable regardless of worktree location. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(tutorial): use DiffTabContent for tutorial tab (TOC support) - Export getTutorialFilePath from tutorial-service - Add GET_TUTORIAL_FILE_PATH IPC channel - TabContent tutorial branch uses DiffTabContent with previewOnly+readOnly Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tutorial): use resources/tutorial.md for dev-mode path Copies tutorial-v2.md into resources/tutorial.md so build:resources puts it at dist/resources/tutorial.md — same path as __dirname/resources/ in dev mode, matching prod behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tutorial): use getTutorialContent + MarkdownRichEditor instead of DiffTabContent DiffTabContent's path auth rejects the tutorial file. Switch to getTutorialContent IPC (no auth) + MarkdownRichEditor + MarkdownToc directly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tutorial): move TOC to left, use markdownTocOpenAtom Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(tutorial): remove unused getTutorialFilePath IPC chain and dead settings tab 清理迭代遗留:删除全程未被渲染层消费的 getTutorialFilePath IPC 链路 (chat 常量 / ipc handler / preload 绑定,函数降级为模块内私有), 并移除 SettingsPanel 中因 handleTabChange 拦截而不可达的 tutorial 分支 及孤儿组件 TutorialViewer。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8801461 commit b33d5ef

13 files changed

Lines changed: 965 additions & 220 deletions

File tree

apps/electron/electron-builder.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ extraResources:
5555
filter:
5656
- "**/*"
5757
# 教程文件(教程查看器 + 欢迎对话使用)
58-
- from: ../../tutorial/tutorial.md
58+
- from: ../../tutorial/tutorial-v2.md
5959
to: tutorial.md
6060
# Proma 品牌 Logo 素材(品牌素材下载功能使用)
6161
- from: resources/proma-logos

apps/electron/resources/tutorial.md

Lines changed: 432 additions & 0 deletions
Large diffs are not rendered by default.

apps/electron/src/main/lib/tutorial-service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ function getTutorialFilePath(): string {
2222
if (app.isPackaged) {
2323
return join(process.resourcesPath, 'tutorial.md')
2424
}
25-
// 开发模式:app.getAppPath() → apps/electron/
26-
return join(app.getAppPath(), '../../tutorial/tutorial.md')
25+
// 开发模式:resources/ 经 build:resources 复制到 dist/resources/
26+
return join(__dirname, 'resources/tutorial.md')
2727
}
2828

2929
/**

apps/electron/src/renderer/App.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { TooltipProvider } from './components/ui/tooltip'
99
import { SettingsDialog } from './components/settings/SettingsDialog'
1010
import { conversationsAtom } from './atoms/chat-atoms'
1111
import { environmentCheckDialogOpenAtom } from './atoms/environment'
12-
import { tabsAtom, activeTabIdAtom, openTab } from './atoms/tab-atoms'
12+
import { tabsAtom, activeTabIdAtom, openTab, TUTORIAL_TAB_ID } from './atoms/tab-atoms'
1313
import type { AppShellContextType } from './contexts/AppShellContext'
1414

1515
export default function App(): React.ReactElement {
@@ -44,18 +44,24 @@ export default function App(): React.ReactElement {
4444
initialize()
4545
}, [])
4646

47-
// 完成 onboarding 回调:创建欢迎对话
48-
const handleOnboardingComplete = async () => {
47+
// 完成 onboarding 回调:创建欢迎对话,可选打开教程 Tab
48+
const handleOnboardingComplete = async (openTutorial?: boolean) => {
4949
setShowOnboarding(false)
5050

51+
if (openTutorial) {
52+
const tabs = store.get(tabsAtom)
53+
const result = openTab(tabs, { type: 'tutorial', sessionId: TUTORIAL_TAB_ID, title: 'Proma 使用教程' })
54+
store.set(tabsAtom, result.tabs)
55+
store.set(activeTabIdAtom, result.activeTabId)
56+
return
57+
}
58+
5159
try {
5260
const meta = await window.electronAPI.createWelcomeConversation()
5361
if (meta) {
54-
// 添加到对话列表
5562
const conversations = store.get(conversationsAtom)
5663
store.set(conversationsAtom, [meta, ...conversations])
5764

58-
// 打开对话标签页
5965
const tabs = store.get(tabsAtom)
6066
const result = openTab(tabs, {
6167
type: 'chat',

apps/electron/src/renderer/atoms/tab-atoms.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@ import type { PreviewFile } from './preview-atoms'
2222
// ===== 类型定义 =====
2323

2424
/** 标签页类型(Settings 不作为 Tab,保留独立视图) */
25-
export type TabType = 'chat' | 'agent' | 'scratch' | 'preview'
25+
export type TabType = 'chat' | 'agent' | 'scratch' | 'preview' | 'tutorial'
2626

2727
/** Scratch Pad 专用的固定 sessionId */
2828
export const SCRATCH_PAD_ID = '__scratch-pad__'
2929

30+
/** 教程 Tab 固定 ID */
31+
export const TUTORIAL_TAB_ID = '__tutorial__'
32+
export const TUTORIAL_TAB_TITLE = 'Proma 使用教程'
33+
3034
/** 会话预览 Tab 的 ID 前缀:运行时临时入口,不参与持久化 */
3135
const PREVIEW_TAB_PREFIX = '__preview__:'
3236

@@ -202,7 +206,7 @@ function isSessionTab(tab: TabItem): boolean {
202206
}
203207

204208
function getPersistentTabs(tabs: TabItem[]): TabItem[] {
205-
return tabs.filter((tab) => tab.id !== SCRATCH_PAD_ID && !isPreviewTab(tab))
209+
return tabs.filter((tab) => tab.id !== SCRATCH_PAD_ID && tab.id !== TUTORIAL_TAB_ID && !isPreviewTab(tab))
206210
}
207211

208212
export function getPersistableTabState(
@@ -239,6 +243,19 @@ export function openTab(
239243
}
240244
}
241245

246+
if (item.type === 'tutorial') {
247+
const tutorialTab: TabItem = tabs.find((t) => t.id === TUTORIAL_TAB_ID) ?? {
248+
id: TUTORIAL_TAB_ID,
249+
type: 'tutorial',
250+
sessionId: TUTORIAL_TAB_ID,
251+
title: TUTORIAL_TAB_TITLE,
252+
}
253+
return {
254+
tabs: [scratchTab, tutorialTab],
255+
activeTabId: TUTORIAL_TAB_ID,
256+
}
257+
}
258+
242259
if (item.type === 'preview') {
243260
const ownerAgentTab = tabs.find((t) => t.type === 'agent' && t.sessionId === item.sessionId) ?? {
244261
id: item.sessionId,

apps/electron/src/renderer/components/onboarding/OnboardingView.tsx

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,24 @@ import { useMemo, useState } from 'react'
1212
import { useAtomValue, useSetAtom } from 'jotai'
1313
import { GraduationCap, ChevronRight, ChevronLeft, HardDriveDownload, Users } from 'lucide-react'
1414
import { Button } from '@/components/ui/button'
15-
import {
16-
Sheet,
17-
SheetContent,
18-
SheetHeader,
19-
SheetTitle,
20-
} from '@/components/ui/sheet'
21-
import { ScrollArea } from '@/components/ui/scroll-area'
22-
import { TutorialViewer } from '@/components/tutorial/TutorialViewer'
2315
import { EnvironmentCheckPanel } from '@/components/environment/EnvironmentCheckPanel'
2416
import { isShellEnvironmentOkAtom } from '@/atoms/environment'
2517
import { detectIsWindows } from '@/lib/platform'
2618
import { migrationImportDialogOpenAtom } from '@/atoms/migration-atoms'
2719

2820
interface OnboardingViewProps {
29-
/** 完成回调(进入主界面) */
30-
onComplete: () => void
21+
onComplete: (openTutorial?: boolean) => void
3122
}
3223

3324
export function OnboardingView({ onComplete }: OnboardingViewProps) {
34-
const [showTutorial, setShowTutorial] = useState(false)
3525
const [step, setStep] = useState<'welcome' | 'environment'>('welcome')
3626
const isWindows = useMemo(() => detectIsWindows(), [])
3727
const shellOk = useAtomValue(isShellEnvironmentOkAtom)
3828
const setMigrationImportDialogOpen = useSetAtom(migrationImportDialogOpenAtom)
3929

40-
const handleFinish = async () => {
41-
await window.electronAPI.updateSettings({
42-
onboardingCompleted: true,
43-
})
44-
onComplete()
30+
const handleFinish = async (openTutorial?: boolean) => {
31+
await window.electronAPI.updateSettings({ onboardingCompleted: true })
32+
onComplete(openTutorial)
4533
}
4634

4735
const handleNextFromWelcome = () => {
@@ -70,7 +58,7 @@ export function OnboardingView({ onComplete }: OnboardingViewProps) {
7058
<div className="w-full max-w-2xl">
7159
<div className="space-y-3">
7260
<button
73-
onClick={() => setShowTutorial(true)}
61+
onClick={() => handleFinish(true)}
7462
className="w-full rounded-xl bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5 border border-primary/15 p-4 flex items-center gap-4 hover:from-primary/10 hover:via-primary/15 hover:to-primary/10 transition-colors text-left"
7563
>
7664
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
@@ -170,7 +158,7 @@ export function OnboardingView({ onComplete }: OnboardingViewProps) {
170158
</Button>
171159
<div className="flex gap-3">
172160
<Button
173-
onClick={handleFinish}
161+
onClick={() => handleFinish()}
174162
variant={shellOk ? 'default' : 'outline'}
175163
>
176164
{shellOk ? '开始使用' : '稍后处理(进入主界面)'}
@@ -179,22 +167,6 @@ export function OnboardingView({ onComplete }: OnboardingViewProps) {
179167
</div>
180168
</div>
181169
)}
182-
183-
<Sheet open={showTutorial} onOpenChange={setShowTutorial}>
184-
<SheetContent side="right" className="w-[560px] sm:max-w-[560px] p-0">
185-
<SheetHeader className="px-6 pt-6 pb-4 border-b">
186-
<SheetTitle className="flex items-center gap-2">
187-
<GraduationCap size={18} className="text-primary" />
188-
Proma 使用教程
189-
</SheetTitle>
190-
</SheetHeader>
191-
<ScrollArea className="h-[calc(100vh-80px)]">
192-
<div className="px-6 py-4">
193-
<TutorialViewer />
194-
</div>
195-
</ScrollArea>
196-
</SheetContent>
197-
</Sheet>
198170
</div>
199171
)
200172
}

apps/electron/src/renderer/components/settings/SettingsPanel.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import * as React from "react";
9-
import { useAtom, useAtomValue } from "jotai";
9+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
1010
import { cn } from "@/lib/utils";
1111
import {
1212
Settings,
@@ -26,10 +26,11 @@ import {
2626
HardDrive,
2727
} from "lucide-react";
2828
import { ScrollArea } from "@/components/ui/scroll-area";
29-
import { settingsTabAtom, channelFormDirtyAtom, settingsCloseRequestedAtom } from "@/atoms/settings-tab";
29+
import { settingsTabAtom, channelFormDirtyAtom, settingsCloseRequestedAtom, settingsOpenAtom } from "@/atoms/settings-tab";
3030
import type { SettingsTab } from "@/atoms/settings-tab";
3131
import { appModeAtom } from "@/atoms/app-mode";
3232
import { hasUpdateAtom } from "@/atoms/updater";
33+
import { tabsAtom, activeTabIdAtom, openTab, TUTORIAL_TAB_ID } from "@/atoms/tab-atoms";
3334
import { hasEnvironmentIssuesAtom } from "@/atoms/environment";
3435
import {
3536
AlertDialog,
@@ -50,7 +51,6 @@ import { AgentSettings } from "./AgentSettings";
5051
import { PromptSettings } from "./PromptSettings";
5152
import { ToolSettings } from "./ToolSettings";
5253
import { BotHubSettings } from "./BotHubSettings";
53-
import { TutorialViewer } from "../tutorial/TutorialViewer";
5454
import { ShortcutSettings } from "./ShortcutSettings";
5555
import { VoiceInputSettings } from "./VoiceInputSettings";
5656
import { MigrationSettings } from "./MigrationSettings";
@@ -132,8 +132,6 @@ function renderTabContent(tab: SettingsTab): React.ReactElement {
132132
return <AboutSettings />;
133133
case "bots":
134134
return <BotHubSettings />;
135-
case "tutorial":
136-
return <TutorialViewer />;
137135
case "shortcuts":
138136
return <ShortcutSettings />;
139137
case "voice-input":
@@ -142,6 +140,9 @@ function renderTabContent(tab: SettingsTab): React.ReactElement {
142140
return <MigrationSettings />;
143141
case "storage":
144142
return <StorageSettings />;
143+
default:
144+
// tutorial 等特殊 tab 由 handleTabChange 拦截打开主区 Tab,不会在此渲染
145+
return <GeneralSettings />;
145146
}
146147
}
147148

@@ -155,9 +156,12 @@ export function SettingsPanel({
155156
const [activeTab, setActiveTab] = useAtom(settingsTabAtom);
156157
const channelFormDirty = useAtomValue(channelFormDirtyAtom);
157158
const [closeRequested, setCloseRequested] = useAtom(settingsCloseRequestedAtom);
159+
const setSettingsOpen = useSetAtom(settingsOpenAtom);
158160
const appMode = useAtomValue(appModeAtom);
159161
const hasUpdate = useAtomValue(hasUpdateAtom);
160162
const hasEnvironmentIssues = useAtomValue(hasEnvironmentIssuesAtom);
163+
const [mainTabs, setMainTabs] = useAtom(tabsAtom);
164+
const setMainActiveTabId = useSetAtom(activeTabIdAtom);
161165

162166
/** 统一的退出拦截对话框状态 */
163167
type PendingAction = { type: 'tab'; tabId: SettingsTab } | { type: 'close' } | null
@@ -180,8 +184,15 @@ export function SettingsPanel({
180184
setPendingAction(null)
181185
}
182186

183-
/** 切换标签页时检测是否有未保存内容 */
187+
/** 切换标签页时检测是否有未保存内容,tutorial 特殊处理:打开 New Tab 并关闭设置 */
184188
const handleTabChange = (tabId: SettingsTab): void => {
189+
if (tabId === 'tutorial') {
190+
const result = openTab(mainTabs, { type: 'tutorial', sessionId: TUTORIAL_TAB_ID, title: 'Proma 使用教程' })
191+
setMainTabs(result.tabs)
192+
setMainActiveTabId(result.activeTabId)
193+
setSettingsOpen(false)
194+
return
195+
}
185196
if (tabId === activeTab) return
186197
if (activeTab === 'channels' && channelFormDirty) {
187198
setPendingAction({ type: 'tab', tabId })

apps/electron/src/renderer/components/tabs/TabBar.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,7 @@ export function TabBar(): React.ReactElement {
108108
agentWorkspaceId: session.workspaceId,
109109
}).catch(console.error)
110110
}
111-
} else if (tab.type === 'scratch') {
112-
// Agent 模式下切到 Scratch Pad 时保持右侧文件面板不收起
111+
} else if (tab.type === 'scratch' || tab.type === 'tutorial') {
113112
setCurrentConversationId(null)
114113
if (appMode !== 'agent') {
115114
setCurrentAgentSessionId(null)

apps/electron/src/renderer/components/tabs/TabContent.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
*/
77

88
import * as React from 'react'
9-
import { useAtomValue } from 'jotai'
9+
import { useAtom, useAtomValue } from 'jotai'
1010
import { tabsAtom } from '@/atoms/tab-atoms'
11+
import { markdownTocOpenAtom } from '@/atoms/markdown-toc'
1112
import { ChatView } from '@/components/chat'
1213
import { AgentView } from '@/components/agent'
1314
import { PreviewTabContent } from '@/components/diff/PreviewTabContent'
15+
import { MarkdownRichEditor } from '@/components/diff/MarkdownRichEditor'
16+
import { MarkdownToc } from '@/components/diff/MarkdownToc'
1417
import { ScratchPadView } from '@/components/scratch-pad/ScratchPadView'
1518
import { TabErrorBoundary } from './TabErrorBoundary'
1619

@@ -41,6 +44,10 @@ export function TabContent({ tabId }: TabContentProps): React.ReactElement {
4144
return <ScratchPadView />
4245
}
4346

47+
if (tab.type === 'tutorial') {
48+
return <TutorialTabContent />
49+
}
50+
4451
if (tab.type === 'chat') {
4552
return (
4653
<TabErrorBoundary key={tab.sessionId} sessionId={tab.sessionId}>
@@ -63,3 +70,30 @@ export function TabContent({ tabId }: TabContentProps): React.ReactElement {
6370
</TabErrorBoundary>
6471
)
6572
}
73+
74+
function TutorialTabContent(): React.ReactElement {
75+
const [content, setContent] = React.useState<string | null>(null)
76+
const [tocOpen] = useAtom(markdownTocOpenAtom)
77+
const scrollRef = React.useRef<HTMLDivElement>(null)
78+
79+
React.useEffect(() => {
80+
window.electronAPI.getTutorialContent().then(setContent).catch(console.error)
81+
}, [])
82+
83+
if (content === null) return <div className="flex h-full items-center justify-center text-xs text-muted-foreground">加载中...</div>
84+
85+
return (
86+
<div className="relative flex h-full min-h-0 overflow-hidden">
87+
<MarkdownToc containerRef={scrollRef as React.RefObject<HTMLElement>} contentKey={content.slice(0, 100)} enabled={tocOpen} />
88+
<div ref={scrollRef} className="flex-1 min-w-0 overflow-y-auto p-8">
89+
<MarkdownRichEditor
90+
value={content}
91+
editing={false}
92+
onChange={() => {}}
93+
onSave={() => {}}
94+
onCancel={() => {}}
95+
/>
96+
</div>
97+
</div>
98+
)
99+
}

apps/electron/src/renderer/components/tutorial/TutorialBanner.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,41 +8,38 @@
88
*/
99

1010
import * as React from 'react'
11-
import { useSetAtom } from 'jotai'
11+
import { useAtom, useSetAtom } from 'jotai'
1212
import { GraduationCap, X } from 'lucide-react'
1313
import { Button } from '@/components/ui/button'
14-
import { settingsTabAtom, settingsOpenAtom } from '@/atoms/settings-tab'
14+
import { tabsAtom, activeTabIdAtom, openTab, TUTORIAL_TAB_ID } from '@/atoms/tab-atoms'
1515

1616
export function TutorialBanner(): React.ReactElement | null {
1717
const [visible, setVisible] = React.useState(false)
1818
const [dismissed, setDismissed] = React.useState(true)
19-
const setSettingsOpen = useSetAtom(settingsOpenAtom)
20-
const setSettingsTab = useSetAtom(settingsTabAtom)
19+
const [tabs, setTabs] = useAtom(tabsAtom)
20+
const setActiveTabId = useSetAtom(activeTabIdAtom)
2121

22-
// 初始化:从主进程读取 settings 判断是否需要显示
2322
React.useEffect(() => {
2423
window.electronAPI
2524
.getSettings()
2625
.then((settings) => {
2726
if (!settings.tutorialBannerDismissed) {
2827
setDismissed(false)
29-
// 延迟 1.5 秒显示,避免页面加载时的干扰
3028
setTimeout(() => setVisible(true), 1500)
3129
}
3230
})
3331
.catch(console.error)
3432
}, [])
3533

36-
// 关闭横幅并持久化
3734
const handleDismiss = async () => {
3835
setVisible(false)
3936
await window.electronAPI.updateSettings({ tutorialBannerDismissed: true })
4037
}
4138

42-
// 立即学习:跳转到设置教程页并关闭横幅
4339
const handleLearnNow = async () => {
44-
setSettingsTab('tutorial')
45-
setSettingsOpen(true)
40+
const result = openTab(tabs, { type: 'tutorial', sessionId: TUTORIAL_TAB_ID, title: 'Proma 使用教程' })
41+
setTabs(result.tabs)
42+
setActiveTabId(result.activeTabId)
4643
await handleDismiss()
4744
}
4845

@@ -102,7 +99,7 @@ export function TutorialBanner(): React.ReactElement | null {
10299

103100
{/* 提示文字 */}
104101
<p className="text-[11px] text-muted-foreground/60 mt-3 text-center">
105-
你可以随时在 设置 &gt; 教程 中查看完整教程
102+
你可以随时点击顶栏「教程」标签重新打开
106103
</p>
107104
</div>
108105
</div>

0 commit comments

Comments
 (0)