From fc9e373eb664bc974e86e685101b25ccb3b19996 Mon Sep 17 00:00:00 2001 From: shuguang Date: Fri, 15 May 2026 22:59:57 +0800 Subject: [PATCH 1/2] feat: add first-run onboarding flow Add a guided first-run setup so users can configure a local knowledge repository and API settings before entering the reader. Co-Authored-By: Claude Sonnet 4.6 --- .../server/increa_reader/config_routes.py | 89 +++++- packages/server/increa_reader/workspace.py | 12 + packages/ui/src/app/api.ts | 37 +++ packages/ui/src/app/app.tsx | 2 + packages/ui/src/app/layout.tsx | 24 +- packages/ui/src/app/onboarding.tsx | 262 ++++++++++++++++++ 6 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/app/onboarding.tsx diff --git a/packages/server/increa_reader/config_routes.py b/packages/server/increa_reader/config_routes.py index a260125..fd0cdff 100644 --- a/packages/server/increa_reader/config_routes.py +++ b/packages/server/increa_reader/config_routes.py @@ -2,13 +2,22 @@ Configuration API routes """ +import os +import platform +import sys from pathlib import Path from fastapi import FastAPI from pydantic import BaseModel from .models import RepoItem, WorkspaceConfig -from .workspace import load_api_settings, save_api_settings, save_workspace_config +from .workspace import ( + is_onboarding_complete, + load_api_settings, + mark_onboarding_complete, + save_api_settings, + save_workspace_config, +) class RepoEntry(BaseModel): @@ -25,6 +34,30 @@ class ApiSettingsRequest(BaseModel): default_model: str | None = None +class CompleteOnboardingRequest(BaseModel): + repo_path: str + api_key: str | None = None + base_url: str | None = None + auth_token: str | None = None + default_model: str | None = None + + +def _has_real_value(value: str | None) -> bool: + if not value: + return False + normalized = value.strip().strip('"').strip("'") + return bool(normalized and normalized not in {"your-api-key", "your-proxy-token"}) + + +def _check_pdf_support() -> dict: + try: + import fitz # type: ignore + + return {"ok": True, "detail": f"PyMuPDF {fitz.version[0]}"} + except Exception as exc: + return {"ok": False, "detail": str(exc)} + + def _mask_api_key(key: str | None) -> str | None: """Mask API key for display: 'sk-ant-api03-xxxxx' → 'sk-ant-a...yyyy'""" if not key or len(key) < 12: @@ -112,3 +145,57 @@ async def update_api_settings(request: ApiSettingsRequest): "api_key": _mask_api_key(updated.get("api_key")), "default_model": updated["default_model"], } + + @app.get("/api/config/onboarding") + async def get_onboarding_state(): + """Return first-run setup state and environment diagnostics""" + settings = load_api_settings() + has_auth = any( + [ + _has_real_value(settings.get("api_key")), + _has_real_value(os.getenv("ANTHROPIC_API_KEY")), + _has_real_value(os.getenv("ANTHROPIC_AUTH_TOKEN")), + ] + ) + valid_repos = [repo for repo in workspace_config.repos if Path(repo.root).exists()] + return { + "completed": is_onboarding_complete(), + "needs_onboarding": not is_onboarding_complete() or not valid_repos, + "has_repos": bool(valid_repos), + "has_api_credentials": has_auth, + "diagnostics": { + "backend": {"ok": True, "detail": "FastAPI server is reachable"}, + "python": { + "ok": sys.version_info >= (3, 10), + "detail": f"Python {platform.python_version()}", + }, + "pdf": _check_pdf_support(), + }, + } + + @app.post("/api/config/onboarding/complete") + async def complete_onboarding(request: CompleteOnboardingRequest): + """Save first-run setup settings and mark onboarding complete""" + path_obj = Path(request.repo_path).expanduser().resolve() + repo = RepoItem(name=path_obj.name, root=str(path_obj)) + save_workspace_config([repo]) + workspace_config.repos.clear() + workspace_config.repos.append(repo) + + api_key = request.api_key.strip() if request.api_key else None + auth_token = request.auth_token.strip() if request.auth_token else None + settings = { + "base_url": request.base_url.strip() if request.base_url else None, + "api_key": api_key or auth_token, + "default_model": request.default_model.strip() if request.default_model else None, + } + save_api_settings(settings) + mark_onboarding_complete() + return { + "repo": {"name": repo.name, "root": repo.root, "exists": path_obj.exists()}, + "api_settings": { + "base_url": settings["base_url"], + "api_key": _mask_api_key(settings.get("api_key")), + "default_model": settings["default_model"], + }, + } diff --git a/packages/server/increa_reader/workspace.py b/packages/server/increa_reader/workspace.py index 50012ff..3674c87 100644 --- a/packages/server/increa_reader/workspace.py +++ b/packages/server/increa_reader/workspace.py @@ -69,6 +69,18 @@ def save_api_settings(settings: dict) -> None: save_raw_config(data) +def mark_onboarding_complete() -> None: + """Persist that the first-run setup has been completed""" + data = load_raw_config() + data["onboarding_completed"] = True + save_raw_config(data) + + +def is_onboarding_complete() -> bool: + """Return whether first-run setup has been completed""" + return bool(load_raw_config().get("onboarding_completed")) + + def _load_repos_from_config() -> List[RepoItem] | None: """Try loading repos from config.json, return None if not found""" data = load_raw_config() diff --git a/packages/ui/src/app/api.ts b/packages/ui/src/app/api.ts index 678f2ab..b56f632 100644 --- a/packages/ui/src/app/api.ts +++ b/packages/ui/src/app/api.ts @@ -143,6 +143,26 @@ export type ApiSettings = { default_model: string | null } +export type OnboardingState = { + completed: boolean + needs_onboarding: boolean + has_repos: boolean + has_api_credentials: boolean + diagnostics: { + backend: { ok: boolean; detail: string } + python: { ok: boolean; detail: string } + pdf: { ok: boolean; detail: string } + } +} + +export type CompleteOnboardingRequest = { + repo_path: string + api_key?: string | null + base_url?: string | null + auth_token?: string | null + default_model?: string | null +} + export async function fetchApiSettings(): Promise { const response = await fetch('/api/config/api-settings') return response.json() @@ -157,4 +177,21 @@ export async function updateApiSettings(settings: Partial): Promise return response.json() } +export async function fetchOnboardingState(): Promise { + const response = await fetch('/api/config/onboarding') + return response.json() +} + +export async function completeOnboarding(settings: CompleteOnboardingRequest): Promise { + const response = await fetch('/api/config/onboarding/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }) + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.detail || 'Failed to complete onboarding') + } +} + export type { RepoConfigInfo, RepoInfo, RepoTreeData, TreeNode } diff --git a/packages/ui/src/app/app.tsx b/packages/ui/src/app/app.tsx index 77c7755..224ab07 100644 --- a/packages/ui/src/app/app.tsx +++ b/packages/ui/src/app/app.tsx @@ -2,6 +2,7 @@ import { Route, Routes } from 'react-router-dom' import { VisibleContentProvider } from '../contexts/visible-content-context' import { BoardViewer } from './board-viewer' import { Layout } from './layout' +import { OnboardingPage } from './onboarding' import { TabbedViewer } from './tabs/tabbed-viewer' function HomePage() { @@ -16,6 +17,7 @@ function App() { return ( + } /> }> } /> } /> diff --git a/packages/ui/src/app/layout.tsx b/packages/ui/src/app/layout.tsx index 1b97223..c3cc61e 100644 --- a/packages/ui/src/app/layout.tsx +++ b/packages/ui/src/app/layout.tsx @@ -1,11 +1,33 @@ -import { Outlet } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { Outlet, useNavigate } from 'react-router-dom' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' +import { fetchOnboardingState } from './api' import { ChatPanel } from './chat' import { LeftPanel } from './left-panel' export function Layout() { + const navigate = useNavigate() + const [checkingOnboarding, setCheckingOnboarding] = useState(true) + + useEffect(() => { + fetchOnboardingState() + .then(state => { + if (state.needs_onboarding) navigate('/onboarding', { replace: true }) + }) + .catch(console.error) + .finally(() => setCheckingOnboarding(false)) + }, [navigate]) + + if (checkingOnboarding) { + return ( +
+ Preparing workspace... +
+ ) + } + return ( diff --git a/packages/ui/src/app/onboarding.tsx b/packages/ui/src/app/onboarding.tsx new file mode 100644 index 0000000..5a5362f --- /dev/null +++ b/packages/ui/src/app/onboarding.tsx @@ -0,0 +1,262 @@ +import { + ArrowRight, + CheckCircle2, + FolderOpen, + KeyRound, + Loader2, + MapIcon, + Server, + XCircle, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { completeOnboarding, fetchOnboardingState, type OnboardingState } from './api' + +type Step = 'repo' | 'api' | 'check' + +function DiagnosticItem({ label, ok, detail }: { label: string; ok: boolean; detail: string }) { + return ( +
+
+
{label}
+
{detail}
+
+ {ok ? ( + + ) : ( + + )} +
+ ) +} + +export function OnboardingPage() { + const navigate = useNavigate() + const [step, setStep] = useState('repo') + const [state, setState] = useState(null) + const [repoPath, setRepoPath] = useState('') + const [apiKey, setApiKey] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const [authToken, setAuthToken] = useState('') + const [defaultModel, setDefaultModel] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + fetchOnboardingState() + .then(data => { + setState(data) + if (!data.needs_onboarding) navigate('/', { replace: true }) + }) + .catch(() => + setError('Unable to connect to the backend service. Make sure the server is running.'), + ) + .finally(() => setLoading(false)) + }, [navigate]) + + const handleComplete = async () => { + if (!repoPath.trim()) return + setSaving(true) + setError(null) + try { + await completeOnboarding({ + repo_path: repoPath.trim(), + api_key: apiKey.trim() || null, + base_url: baseUrl.trim() || null, + auth_token: authToken.trim() || null, + default_model: defaultModel.trim() || null, + }) + navigate('/', { replace: true }) + window.location.reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save configuration') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ Checking runtime environment... +
+ ) + } + + const diagnostics = state?.diagnostics + + return ( +
+
+ + +
+
+ + 1 Open repository + + + 2 API setup + + + 3 Environment check + +
+ + {step === 'repo' && ( +
+
+

Choose a local folder / repository

+

+ Enter an absolute local path, for example /Users/you/Documents/notes. Browsers + cannot expose real folder paths directly for security reasons. +

+
+ setRepoPath(e.target.value)} + onKeyDown={e => e.key === 'Enter' && repoPath.trim() && setStep('api')} + /> + +
+ )} + + {step === 'api' && ( +
+
+

Connect Claude API

+

+ Use an official API key or a compatible proxy. You can skip this for now and start + reading files first. +

+
+ setApiKey(e.target.value)} + /> + setBaseUrl(e.target.value)} + /> + setAuthToken(e.target.value)} + /> + setDefaultModel(e.target.value)} + /> +
+ + +
+
+ )} + + {step === 'check' && ( +
+
+

Preflight check

+

+ Confirm that the backend, Python, and PDF support are working before entering the + reader. +

+
+
+ {diagnostics && ( + <> + + + + + )} + + +
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+ )} +
+
+
+ ) +} From 8d99f2ef8355310f0d917f4ea8442bf74050d7ce Mon Sep 17 00:00:00 2001 From: shuguang Date: Sat, 16 May 2026 00:17:21 +0800 Subject: [PATCH 2/2] feat(ui): add knowledge map home screen Replace the empty home page with a repository overview that summarizes files, folders, content types, and suggested reading entry points. Co-Authored-By: Claude Sonnet 4.6 --- packages/ui/src/app/app.tsx | 11 +- packages/ui/src/app/knowledge-map.tsx | 365 ++++++++++++++++++++++++++ 2 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/app/knowledge-map.tsx diff --git a/packages/ui/src/app/app.tsx b/packages/ui/src/app/app.tsx index 224ab07..8632803 100644 --- a/packages/ui/src/app/app.tsx +++ b/packages/ui/src/app/app.tsx @@ -1,25 +1,18 @@ import { Route, Routes } from 'react-router-dom' import { VisibleContentProvider } from '../contexts/visible-content-context' import { BoardViewer } from './board-viewer' +import { KnowledgeMap } from './knowledge-map' import { Layout } from './layout' import { OnboardingPage } from './onboarding' import { TabbedViewer } from './tabs/tabbed-viewer' -function HomePage() { - return ( -
-

AI Chat

-
- ) -} - function App() { return ( } /> }> - } /> + } /> } /> } /> diff --git a/packages/ui/src/app/knowledge-map.tsx b/packages/ui/src/app/knowledge-map.tsx new file mode 100644 index 0000000..de8a219 --- /dev/null +++ b/packages/ui/src/app/knowledge-map.tsx @@ -0,0 +1,365 @@ +import { + ArrowRight, + BookOpen, + FileCode, + FileText, + FileType, + FolderOpen, + Image, + Loader2, + MapIcon, + Sparkles, +} from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { Button } from '@/components/ui/button' + +import { fetchRepos, fetchRepoTree, type RepoInfo, type TreeNode } from './api' + +type FileEntry = { + repo: string + name: string + path: string + depth: number + extension: string +} + +type RepoMap = { + repo: RepoInfo + files: FileEntry[] + directoryCount: number +} + +type FileCategory = 'documents' | 'pdfs' | 'code' | 'images' | 'other' + +const DOCUMENT_EXTENSIONS = new Set(['md', 'mdx', 'txt', 'rst', 'doc', 'docx']) +const CODE_EXTENSIONS = new Set([ + 'js', + 'jsx', + 'ts', + 'tsx', + 'py', + 'go', + 'rs', + 'java', + 'kt', + 'c', + 'cpp', + 'h', + 'hpp', + 'css', + 'scss', + 'json', + 'yaml', + 'yml', + 'toml', +]) +const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']) + +function getExtension(name: string) { + const index = name.lastIndexOf('.') + return index === -1 ? '' : name.slice(index + 1).toLowerCase() +} + +function getCategory(file: FileEntry): FileCategory { + if (file.extension === 'pdf') return 'pdfs' + if (DOCUMENT_EXTENSIONS.has(file.extension)) return 'documents' + if (CODE_EXTENSIONS.has(file.extension)) return 'code' + if (IMAGE_EXTENSIONS.has(file.extension)) return 'images' + return 'other' +} + +function flattenTree( + nodes: TreeNode[], + repo: string, + depth = 0, +): { files: FileEntry[]; dirs: number } { + const result: FileEntry[] = [] + let dirs = 0 + + for (const node of nodes) { + if (node.type === 'dir') { + dirs += 1 + const childResult = flattenTree(node.children ?? [], repo, depth + 1) + result.push(...childResult.files) + dirs += childResult.dirs + } else { + result.push({ + repo, + name: node.name, + path: node.path, + depth, + extension: getExtension(node.name), + }) + } + } + + return { files: result, dirs } +} + +function scoreReadingCandidate(file: FileEntry) { + const lowerName = file.name.toLowerCase() + const lowerPath = file.path.toLowerCase() + let score = 0 + + if (lowerName === 'readme.md') score += 100 + if (lowerName.startsWith('readme')) score += 80 + if (lowerName.includes('overview')) score += 60 + if (lowerName.includes('intro') || lowerName.includes('getting-started')) score += 50 + if (lowerName.includes('guide') || lowerName.includes('index')) score += 35 + if (file.extension === 'md') score += 25 + if (file.extension === 'pdf') score += 20 + if (lowerPath.includes('docs/') || lowerPath.includes('notes/')) score += 15 + score -= file.depth * 2 + + return score +} + +function categoryLabel(category: FileCategory) { + switch (category) { + case 'documents': + return 'Notes & docs' + case 'pdfs': + return 'PDFs' + case 'code': + return 'Code & config' + case 'images': + return 'Images' + default: + return 'Other files' + } +} + +function categoryIcon(category: FileCategory) { + switch (category) { + case 'documents': + return + case 'pdfs': + return + case 'code': + return + case 'images': + return + default: + return + } +} + +function openPath(repo: string, path: string) { + const cleanPath = path.startsWith('/') ? path.slice(1) : path + return `/views/${encodeURIComponent(repo)}/${cleanPath}` +} + +export function KnowledgeMap() { + const navigate = useNavigate() + const [repoMaps, setRepoMaps] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadKnowledgeMap = useCallback(async () => { + setLoading(true) + setError(null) + try { + const repos = await fetchRepos() + const maps = await Promise.all( + repos.map(async repo => { + const tree = await fetchRepoTree(repo.name) + const flattened = flattenTree(tree.files, repo.name) + return { + repo, + files: flattened.files, + directoryCount: flattened.dirs, + } + }), + ) + setRepoMaps(maps) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to build knowledge map') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadKnowledgeMap() + }, [loadKnowledgeMap]) + + const allFiles = useMemo(() => repoMaps.flatMap(repoMap => repoMap.files), [repoMaps]) + const totalDirectories = repoMaps.reduce((sum, repoMap) => sum + repoMap.directoryCount, 0) + const categoryCounts = useMemo(() => { + const counts: Record = { + documents: 0, + pdfs: 0, + code: 0, + images: 0, + other: 0, + } + for (const file of allFiles) counts[getCategory(file)] += 1 + return counts + }, [allFiles]) + const readingPath = useMemo( + () => + allFiles + .filter(file => ['documents', 'pdfs'].includes(getCategory(file))) + .map(file => ({ file, score: scoreReadingCandidate(file) })) + .sort((a, b) => b.score - a.score) + .slice(0, 6) + .map(item => item.file), + [allFiles], + ) + const importantFolders = useMemo(() => { + const counts = new Map() + for (const file of allFiles) { + const folder = file.path.includes('/') ? file.path.split('/')[0] : 'Root files' + counts.set(folder, (counts.get(folder) ?? 0) + 1) + } + return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5) + }, [allFiles]) + + if (loading) { + return ( +
+ Building knowledge map... +
+ ) + } + + if (error) { + return ( +
+
+
Unable to build the knowledge map
+

{error}

+ +
+
+ ) + } + + return ( +
+
+
+
+
+
+ Knowledge Map +
+

+ Your reading workspace is ready. +

+

+ Increa Reader scanned your repositories and created a quick map of documents, + folders, and suggested starting points. +

+
+ +
+
+ +
+
+
Repositories
+
{repoMaps.length}
+
+
+
Files
+
{allFiles.length}
+
+
+
Folders
+
{totalDirectories}
+
+
+
Reading candidates
+
{readingPath.length}
+
+
+ +
+
+
+ +

Suggested reading path

+
+ {readingPath.length > 0 ? ( +
+ {readingPath.map((file, index) => ( + + ))} +
+ ) : ( +
+ No Markdown or PDF files were found yet. +
+ )} +
+ +
+
+
+ +

Content mix

+
+
+ {(Object.keys(categoryCounts) as FileCategory[]).map(category => ( +
+
+ {categoryIcon(category)} + {categoryLabel(category)} +
+ + {categoryCounts[category]} + +
+ ))} +
+
+ +
+
+ +

Largest areas

+
+
+ {importantFolders.map(([folder, count]) => ( +
+ {folder} + {count} files +
+ ))} +
+
+
+
+
+
+ ) +}