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
309 changes: 281 additions & 28 deletions DESIGN.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
Expand Down
77 changes: 76 additions & 1 deletion src/main/db/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, desc, and } from 'drizzle-orm'
import { eq, desc, and, sql } from 'drizzle-orm'
import { getDatabase, executeCheckpoint, dropNotebookVectorTable } from './index'
import { chatSessions, chatMessages, notebooks, notes, documents, items } from './schema'
import type {
Expand All @@ -8,6 +8,7 @@ import type {
ChatTokenUsage
} from '../../shared/types/chat'
import type { RetrievalScope } from '../../shared/types/scope'
import type { WorkspaceOverview } from '../../shared/types/workspace'

// ==================== Chat Sessions ====================

Expand Down Expand Up @@ -497,3 +498,77 @@ export function deleteNote(id: string) {
db.delete(notes).where(eq(notes.id, id)).run()
console.log(`[Database] Deleted note: ${id}`)
}

// ==================== Workspace Overview ====================

/**
* Everything the Home page reads, in one round trip (#65).
*
* Three statements rather than a request per notebook: Home is opened constantly
* and an N+1 over the notebook list grows with the library. The source counts are
* one `GROUP BY` merged in JS rather than a correlated subquery per notebook, so
* the number of statements does not depend on how many notebooks exist.
*
* `recentDocuments` orders by `created_at`, which no index covers — the only
* document index is `(notebook_id, updated_at)`. A local SQLite sorting a few
* thousand rows to take 20 is not worth a migration; if the library ever gets big
* enough for that to show, the index is the fix, not a rewrite of this query.
*/
export function getWorkspaceOverview(
options: { notebookLimit?: number; documentLimit?: number; sessionLimit?: number } = {}
): WorkspaceOverview {
const db = getDatabase()

const rows = db
.select()
.from(notebooks)
.orderBy(desc(notebooks.updatedAt))
.limit(options.notebookLimit ?? 200)
.all()

const counts = db
.select({ notebookId: documents.notebookId, count: sql<number>`count(*)` })
.from(documents)
.groupBy(documents.notebookId)
.all()
const countByNotebook = new Map(counts.map((row) => [row.notebookId, row.count]))

const recentDocuments = db
.select({
id: documents.id,
notebookId: documents.notebookId,
notebookTitle: notebooks.title,
title: documents.title,
type: documents.type,
mimeType: documents.mimeType,
sourceUri: documents.sourceUri,
localFilePath: documents.localFilePath,
createdAt: documents.createdAt
})
.from(documents)
.innerJoin(notebooks, eq(documents.notebookId, notebooks.id))
.orderBy(desc(documents.createdAt))
.limit(options.documentLimit ?? 20)
.all()

const recentSessions = db
.select({
id: chatSessions.id,
notebookId: chatSessions.notebookId,
notebookTitle: notebooks.title,
title: chatSessions.title,
updatedAt: chatSessions.updatedAt
})
.from(chatSessions)
.innerJoin(notebooks, eq(chatSessions.notebookId, notebooks.id))
.where(eq(chatSessions.status, 'active'))
.orderBy(desc(chatSessions.updatedAt))
.limit(options.sessionLimit ?? 5)
.all()

return {
notebooks: rows.map((row) => ({ ...row, sourceCount: countByNotebook.get(row.id) ?? 0 })),
recentDocuments,
recentSessions
}
}
11 changes: 11 additions & 0 deletions src/main/ipc/notebookHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createNotebook,
getAllNotebooks,
getNotebookById,
getWorkspaceOverview,
updateNotebook,
deleteNotebook
} from '../db/queries'
Expand Down Expand Up @@ -41,6 +42,16 @@ export function registerNotebookHandlers() {
}
})

// 首页概览:笔记本 + 来源数、最近来源、最近会话,一次读完(#65)
ipcMain.handle('get-workspace-overview', async () => {
try {
return getWorkspaceOverview()
} catch (error) {
console.error('[IPC] Error getting workspace overview:', error)
throw error
}
})

// 获取单个笔记本(带参数验证)
ipcMain.handle(
'get-notebook',
Expand Down
5 changes: 4 additions & 1 deletion src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import type {
LocalEmbeddingModelInfo,
EmbeddingDownloadProgress,
EmbeddingSourceInfo,
SourceBlock
SourceBlock,
WorkspaceOverview
} from '../shared/types'
import type {
KnowledgeDocument,
Expand Down Expand Up @@ -95,6 +96,8 @@ declare global {
// Notebook 相关
createNotebook: (title: string, description?: string) => Promise<Notebook>
getAllNotebooks: () => Promise<Notebook[]>
/** 首页一次读完:笔记本 + 来源数、最近来源、最近会话(#65)。 */
getWorkspaceOverview: () => Promise<WorkspaceOverview>
getNotebook: (id: string) => Promise<Notebook | null>
updateNotebook: (
id: string,
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const api = {
createNotebook: (title: string, description?: string) =>
ipcRenderer.invoke('create-notebook', { title, description }),
getAllNotebooks: () => ipcRenderer.invoke('get-all-notebooks'),
/** 首页一次读完:笔记本 + 来源数、最近来源、最近会话(#65)。 */
getWorkspaceOverview: () => ipcRenderer.invoke('get-workspace-overview'),
getNotebook: (id: string) => ipcRenderer.invoke('get-notebook', { id }),
updateNotebook: (id: string, updates: any) =>
ipcRenderer.invoke('update-notebook', { id, updates }),
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function App(): React.JSX.Element {
// 如果正在加载引导状态,显示加载中
if (onboardingLoading) {
return (
<div className="flex min-h-screen items-center justify-center gap-2 bg-surface-base text-sm text-muted-foreground">
<div className="flex min-h-screen items-center justify-center gap-2 bg-surface-sunken text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('loading')}</span>
</div>
Expand Down
97 changes: 63 additions & 34 deletions src/renderer/src/assets/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,23 @@
* (bg-surface-*, text-muted-foreground, shadow-elevation, ...), never a
* primitive with a literal colour.
*
* Light-mode text contrast against --surface-raised:
* T1 foreground 7.2:1
* T2 muted-foreground 3.3:1
* T3 subtle-foreground 2.2:1 (meta only, never essential information)
* Dark-mode text contrast against --surface-base:
* T1 foreground 4.8:1
* T2 muted-foreground 3.7:1
* T3 subtle-foreground 2.7:1 (meta only, never essential information)
* The neutral ramp is warm (hue ~91, the hue the surface ladder and the text
* ladder share) rather than the zero-chroma grey it used to be. Text and
* surfaces stay two separate ramps; they only agree on hue so a warm surface
* never sits under a cool grey label.
*
* Measured text contrast (WCAG 2.1, computed from the oklch values below):
*
* raised base sunken
* light T1 foreground 13.5:1 12.6:1 11.6:1
* T2 muted-foreground 5.3:1 4.9:1 4.5:1
* T3 subtle-foreground 3.6:1 3.3:1 3.1:1
* dark T1 foreground 13.6:1 14.6:1 15.4:1
* T2 muted-foreground 7.0:1 7.5:1 7.9:1
* T3 subtle-foreground 4.3:1 4.7:1 4.9:1
*
* T3 is meta only and never carries essential information, but it still clears
* 3:1 everywhere; the previous ramp did not (2.9:1 on light raised).
*/
:root {
/*
Expand All @@ -29,25 +38,30 @@
*/
color-scheme: light;

/* Surface ladder: sunken < base < raised < overlay, monotonic in both modes. */
--surface-sunken: oklch(0.9551 0 0);
--surface-base: oklch(0.9851 0 0);
/*
* Surface ladder: sunken < base < raised < overlay, monotonic in both modes.
* sunken the workspace floor — the window background and the gutter between panels
* base the chrome tier — rails (library, notes, settings nav) and title bars
* raised the content tier — documents, the chat, cards, dialogs
*/
--surface-sunken: oklch(0.946 0.0045 91); /* #eeedea */
--surface-base: oklch(0.9756 0.0026 106.45); /* #f7f7f5 */
--surface-raised: oklch(1 0 0);
--surface-overlay: oklch(1 0 0);
/* Translucent state fills: they adapt to whatever surface they are drawn on. */
--surface-hover: oklch(0.3496 0.0141 274.5028 / 0.05);
--surface-selected: oklch(0.3496 0.0141 274.5028 / 0.09);
--surface-hover: oklch(0.302 0.01 91 / 0.05);
--surface-selected: oklch(0.302 0.01 91 / 0.09);
/* Scrim behind a modal layer. The one place a raw black is legitimate, so it
is a token instead of a literal. */
--scrim: oklch(0 0 0 / 0.5);

/* Opaque quiet fill for nested containers (bubbles, icon tiles, code). */
--muted: oklch(0.9551 0 0);
--muted: oklch(0.962 0.0032 91);

/* Text hierarchy: T1 primary, T2 secondary, T3 tertiary. */
--foreground: oklch(0.3496 0.0141 274.5028);
--muted-foreground: oklch(0.58 0.0141 274.5028);
--subtle-foreground: oklch(0.68 0.0107 267.3269);
--foreground: oklch(0.302 0.01 91); /* #302e29 */
--muted-foreground: oklch(0.53 0.01 91); /* #6e6c65 */
--subtle-foreground: oklch(0.625 0.009 91); /* #8a8782 */

/* Accent: primary action, focus ring, selection indicator. */
--primary: oklch(0.6017 0.193 263.2461);
Expand All @@ -58,8 +72,8 @@
--success: oklch(0.55 0.12 150);

/* Hairline. --input is the neutral fill of unchecked controls, not a border. */
--border: oklch(0.9067 0 0);
--input: oklch(0.9551 0 0);
--border: oklch(0.9163 0.0058 84.57); /* #e5e3df */
--input: oklch(0.962 0.0032 91);

--chart-1: oklch(0.7304 0.1213 245.2972);
--chart-2: oklch(0.7683 0.1103 133);
Expand All @@ -71,6 +85,20 @@
--shadow-elevation: 0 6px 16px -4px hsl(0 0% 0% / 0.12), 0 2px 4px -1px hsl(0 0% 0% / 0.06);
--shadow-control: 0 1px 2px 0 hsl(0 0% 0% / 0.16);

/*
* The measure long-form content is set to, in `ch` so it follows the font.
* Every reading surface shares it — the chat transcript and the note editor
* today — so a paragraph is the same width wherever it is read, and widening
* a panel adds margin instead of lengthening the line. 72ch at 14px Inter is
* roughly 600px, inside the 65–75ch band a body copy line wants.
*
* The source reader is the one surface that does not read this yet: its
* highlight rectangles are measured once from the laid-out range
* (`TextSourceReader.tsx`), so a column that re-centres on resize would leave
* the highlight behind. Fix that measurement before capping that surface.
*/
--reading-measure: 72ch;

--font-sans: 'Inter', sans-serif;
--font-serif: 'Lora', serif;
--font-mono: 'Fira Code', monospace;
Expand Down Expand Up @@ -130,31 +158,31 @@
.dark {
color-scheme: dark;

--surface-sunken: oklch(0.24 0.0127 258.3724);
--surface-base: oklch(0.2925 0.0157 264.2965);
--surface-raised: oklch(0.325 0.011 260);
--surface-overlay: oklch(0.365 0.011 260);
--surface-hover: oklch(0.9 0.01 262 / 0.06);
--surface-selected: oklch(0.9 0.01 262 / 0.1);
--surface-sunken: oklch(0.1776 0.0015 91); /* #111110 */
--surface-base: oklch(0.2046 0.0015 91); /* #171716 */
--surface-raised: oklch(0.235 0.0015 91); /* #1e1e1d */
--surface-overlay: oklch(0.285 0.0015 91);
--surface-hover: oklch(0.93 0.003 91 / 0.06);
--surface-selected: oklch(0.93 0.003 91 / 0.1);
--scrim: oklch(0 0 0 / 0.65);

/* --muted is a *recessed* fill: below surface-raised in both modes
(light 0.9551 vs 1.0, dark 0.275 vs 0.325) so a bubble, icon tile or
(light 0.962 vs 1.0, dark 0.187 vs 0.235) so a bubble, icon tile or
inline code block reads as inset instead of flipping direction. */
--muted: oklch(0.275 0.014 260);
--muted: oklch(0.187 0.0015 91);

--foreground: oklch(0.7621 0.0202 262.9873);
--muted-foreground: oklch(0.68 0.02 262.9873);
--subtle-foreground: oklch(0.58 0.02 262.9873);
--foreground: oklch(0.93 0.003 91); /* #e8e8e6 */
--muted-foreground: oklch(0.73 0.004 91); /* #a8a8a5 */
--subtle-foreground: oklch(0.606 0.004 91); /* #83827f */

--success: oklch(0.75 0.13 150);

--primary: oklch(0.7304 0.1213 245.2972);
--ring: oklch(0.7304 0.1213 245.2972);
--destructive: oklch(0.6709 0.1448 16.9989);

--border: oklch(0.3863 0.0235 265.6941);
--input: oklch(0.3863 0.0235 265.6941);
--border: oklch(0.3052 0 89.88); /* #2f2f2f */
--input: oklch(0.3052 0 89.88);

--chart-1: oklch(0.7304 0.1213 245.2972);
--chart-2: oklch(0.7683 0.1103 133);
Expand All @@ -165,8 +193,9 @@
--shadow-elevation: 0 8px 22px -6px hsl(0 0% 0% / 0.55), 0 2px 4px -1px hsl(0 0% 0% / 0.35);
--shadow-control: 0 1px 2px 0 hsl(0 0% 0% / 0.5);

/* A hairline on the sunken rail must step from --surface-sunken, not --border. */
--sidebar-border: oklch(0.3 0.0127 258.3724);
/* A hairline on a chrome rail steps from --surface-base, not from --border: it
divides chrome into sections rather than outlining content. */
--sidebar-border: oklch(0.255 0.0015 91);

--font-sans: 'Inter', sans-serif;
--font-serif: 'Lora', serif;
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/common/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default class AppErrorBoundary extends Component<
}

return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-base p-8 text-center text-foreground">
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-sunken p-8 text-center text-foreground">
<h1 className="text-lg font-medium">{i18n.t('common:errorTitle')}</h1>
<p className="max-w-md text-sm text-muted-foreground">
{i18n.t('common:errorDescription')}
Expand Down
Loading
Loading