Skip to content

Commit 204577d

Browse files
committed
fix: pre-seed databases at build time, show loading screen on startup
Build generates demo.db and querygpt.db with sample data already in place. First launch just copies them over instead of running init logic every time. Also stops re-creating demo connections/terms that users already deleted. Loading spinner shows immediately so startup doesn't feel like it's hanging.
1 parent c17c413 commit 204577d

File tree

7 files changed

+300
-157
lines changed

7 files changed

+300
-157
lines changed

apps/api/app/core/demo_db.py

Lines changed: 21 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
用于演示 QueryGPT 的查询和可视化功能。
55
"""
66

7+
import os
78
import random
89
import sqlite3
910
from datetime import datetime, timedelta
@@ -13,13 +14,17 @@
1314
from sqlalchemy import select, update
1415
from sqlalchemy.ext.asyncio import AsyncSession
1516

16-
from app.db.tables import Connection, SemanticTerm
17-
from app.services.app_settings import get_or_create_app_settings
17+
from app.db.tables import Connection
1818

1919
logger = structlog.get_logger()
2020

21-
# 示例数据库路径
22-
DEMO_DB_PATH = Path(__file__).parent.parent.parent / "data" / "demo.db"
21+
# 示例数据库路径:优先用 DATA_DIR 环境变量(桌面版),否则用源码相对路径
22+
_data_dir = os.environ.get("DATA_DIR")
23+
if _data_dir:
24+
DEMO_DB_PATH = Path(_data_dir) / "demo.db"
25+
else:
26+
DEMO_DB_PATH = Path(__file__).parent.parent.parent / "data" / "demo.db"
27+
2328
DEMO_CONNECTION_NAME = "Sample Database"
2429

2530
# 产品数据
@@ -119,133 +124,21 @@ def init_demo_database() -> str:
119124
return get_demo_db_path()
120125

121126

122-
async def ensure_demo_connection(db: AsyncSession, demo_db_path: str) -> None:
123-
"""在单工作区没有任何连接时,自动补一条示例数据库连接"""
124-
# Migrate old Chinese name to English
125-
old_demo = await db.scalar(select(Connection.id).where(Connection.name == "示例数据库"))
126-
if old_demo:
127-
await db.execute(
128-
update(Connection)
129-
.where(Connection.name == "示例数据库")
130-
.values(name=DEMO_CONNECTION_NAME)
131-
)
132-
await db.commit()
133-
logger.info("Migrated demo connection name to English")
134-
return
135-
136-
has_connections = await db.scalar(select(Connection.id).limit(1))
137-
if has_connections is not None:
138-
return
139-
140-
connection = Connection(
141-
name=DEMO_CONNECTION_NAME,
142-
driver="sqlite",
143-
host=None,
144-
port=None,
145-
username=None,
146-
password_encrypted=None,
147-
database_name=demo_db_path,
148-
extra_options={},
149-
is_default=True,
150-
)
151-
db.add(connection)
152-
await db.flush()
153-
154-
settings_record = await get_or_create_app_settings(db)
155-
settings_record.default_connection_id = connection.id
156-
await db.flush()
157-
158-
logger.info("Seeded demo connection", name=DEMO_CONNECTION_NAME, database=demo_db_path)
159-
160-
161-
# Demo semantic terms (English)
162-
DEMO_SEMANTIC_TERMS = [
163-
{
164-
"term": "GMV",
165-
"expression": "SUM(sales.quantity * sales.unit_price)",
166-
"term_type": "metric",
167-
"description": "Gross Merchandise Value — total sales revenue (quantity × unit price)",
168-
"examples": ["What is this month's GMV?", "GMV by region"],
169-
},
170-
{
171-
"term": "Top Customers",
172-
"expression": "customers.id IN (SELECT customer_id FROM sales GROUP BY customer_id HAVING SUM(amount) > 100000)",
173-
"term_type": "filter",
174-
"description": "Customers with cumulative spending over 100,000",
175-
"examples": ["List all top customers", "Top customers by region"],
176-
},
177-
{
178-
"term": "Average Order Value",
179-
"expression": "AVG(sales.amount)",
180-
"term_type": "metric",
181-
"description": "Average transaction amount per sale",
182-
"examples": ["What is the average order value?", "AOV trend over the past 6 months"],
183-
},
184-
]
185-
186-
# Old Chinese term names to migrate
187-
_CHINESE_TERM_NAMES = {"GMV", "大客户", "客单价"}
188-
189-
190-
async def ensure_demo_semantic_terms(db: AsyncSession, connection_id) -> None:
191-
"""Seed demo semantic terms or migrate old Chinese ones to English."""
192-
from sqlalchemy import func
193-
194-
# Check for old Chinese terms and remove them (will be replaced by English ones)
195-
old_chinese = await db.scalars(
196-
select(SemanticTerm.id).where(SemanticTerm.term.in_(["大客户", "客单价"]))
197-
)
198-
old_ids = list(old_chinese)
199-
200-
# Also check for GMV with Chinese description
201-
gmv_term = await db.scalar(
202-
select(SemanticTerm.id).where(
203-
SemanticTerm.term == "GMV",
204-
SemanticTerm.description.like("%商品交易%"),
127+
async def fix_demo_db_path(db: AsyncSession, demo_db_path: str) -> None:
128+
"""修正预打包数据库中的占位符路径为实际的 demo.db 绝对路径"""
129+
result = await db.scalar(
130+
select(Connection.id).where(
131+
Connection.name == DEMO_CONNECTION_NAME,
132+
Connection.database_name == "__DEMO_DB_PATH__",
205133
)
206134
)
207-
if gmv_term:
208-
old_ids.append(gmv_term)
209-
210-
if old_ids:
211-
from sqlalchemy import delete
212-
213-
await db.execute(delete(SemanticTerm).where(SemanticTerm.id.in_(old_ids)))
214-
logger.info("Removed old Chinese semantic terms", count=len(old_ids))
215-
216-
# Check if English demo terms already exist
217-
existing_count = await db.scalar(
218-
select(func.count())
219-
.select_from(SemanticTerm)
220-
.where(SemanticTerm.term.in_([t["term"] for t in DEMO_SEMANTIC_TERMS]))
221-
)
222-
if existing_count and existing_count >= len(DEMO_SEMANTIC_TERMS):
223-
return
224-
225-
# Seed missing English demo terms
226-
existing_names = set(
227-
await db.scalars(
228-
select(SemanticTerm.term).where(
229-
SemanticTerm.term.in_([t["term"] for t in DEMO_SEMANTIC_TERMS])
230-
)
231-
)
232-
)
233-
for term_data in DEMO_SEMANTIC_TERMS:
234-
if term_data["term"] in existing_names:
235-
continue
236-
term = SemanticTerm(
237-
connection_id=connection_id,
238-
term=term_data["term"],
239-
expression=term_data["expression"],
240-
term_type=term_data["term_type"],
241-
description=term_data["description"],
242-
examples=term_data["examples"],
243-
is_active=True,
135+
if result is not None:
136+
await db.execute(
137+
update(Connection)
138+
.where(Connection.database_name == "__DEMO_DB_PATH__")
139+
.values(database_name=demo_db_path)
244140
)
245-
db.add(term)
246-
247-
await db.flush()
248-
logger.info("Seeded demo semantic terms")
141+
logger.info("Fixed demo connection path", path=demo_db_path)
249142

250143

251144
def _create_tables(cursor: sqlite3.Cursor) -> None:

apps/api/app/db/tables.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class AppSettings(Base, TimestampMixin):
9898
python_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
9999
diagnostics_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
100100
auto_repair_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
101+
demo_initialized: Mapped[bool] = mapped_column(Boolean, default=False)
101102

102103

103104
class SemanticTerm(Base, UUIDMixin, TimestampMixin):

apps/api/app/main.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from app.api.v1 import api_router
1919
from app.core.config import settings
20-
from app.core.demo_db import ensure_demo_connection, ensure_demo_semantic_terms, init_demo_database
20+
from app.core.demo_db import fix_demo_db_path, init_demo_database
2121
from app.db import AsyncSessionLocal, engine
2222
from app.db.base import Base
2323

@@ -67,22 +67,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
6767
await conn.run_sync(Base.metadata.create_all)
6868
logger.info("Database tables created")
6969

70-
# 初始化示例数据库
70+
# 确保 demo.db 存在(构建时已预生成,这里只是 fallback)
7171
demo_db_path = init_demo_database()
72-
logger.info("Demo database initialized", path=demo_db_path)
7372

73+
# 修正预打包数据库中的占位符路径
7474
async with AsyncSessionLocal() as session:
75-
await ensure_demo_connection(session, demo_db_path)
76-
# Seed semantic layer demo terms (needs connection_id)
77-
from sqlalchemy import select as sa_select
78-
79-
from app.db.tables import Connection
80-
81-
demo_conn_id = await session.scalar(
82-
sa_select(Connection.id).where(Connection.name == "Sample Database")
83-
)
84-
if demo_conn_id:
85-
await ensure_demo_semantic_terms(session, demo_conn_id)
75+
await fix_demo_db_path(session, demo_db_path)
8676
await session.commit()
8777

8878
yield

apps/desktop/electron/main.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,58 @@ let processManager: ProcessManager;
1010

1111
const isDev = process.env.NODE_ENV === 'development';
1212

13+
const LOADING_HTML = `data:text/html;charset=utf-8,${encodeURIComponent(`<!DOCTYPE html>
14+
<html><head><meta charset="utf-8"><style>
15+
body { margin:0; height:100vh; display:flex; align-items:center; justify-content:center;
16+
background:#0f0f1a; color:#e0e0e0; font-family:system-ui,-apple-system,sans-serif; }
17+
.wrap { text-align:center; }
18+
.spinner { width:40px; height:40px; margin:0 auto 24px; border:3px solid #333;
19+
border-top-color:#6366f1; border-radius:50%; animation:spin .8s linear infinite; }
20+
@keyframes spin { to { transform:rotate(360deg); } }
21+
h2 { font-size:18px; font-weight:500; margin:0 0 8px; }
22+
p { font-size:13px; color:#888; margin:0; }
23+
</style></head><body>
24+
<div class="wrap">
25+
<div class="spinner"></div>
26+
<h2>QueryGPT</h2>
27+
<p>Starting services...</p>
28+
</div>
29+
</body></html>`)}`;
30+
31+
const ERROR_HTML = (error: unknown) =>
32+
`data:text/html;charset=utf-8,${encodeURIComponent(`<!DOCTYPE html>
33+
<html><head><meta charset="utf-8"><style>
34+
body { margin:0; padding:40px; background:#0f0f1a; color:#e0e0e0; font-family:system-ui; }
35+
code { color:#ff6b6b; display:block; margin:12px 0; white-space:pre-wrap; }
36+
p { color:#888; }
37+
</style></head><body>
38+
<h1>QueryGPT failed to start</h1>
39+
<p>Check the logs for details:</p>
40+
<code>${String(error)}</code>
41+
<p>Logs: ~/.querygpt-desktop/logs/</p>
42+
</body></html>`)}`;
43+
1344
async function createWindow(): Promise<BrowserWindow> {
1445
const win = new BrowserWindow({
1546
width: 1280,
1647
height: 800,
1748
minWidth: 900,
1849
minHeight: 600,
19-
title: '',
50+
title: 'QueryGPT',
2051
webPreferences: {
2152
preload: path.join(__dirname, 'preload.js'),
2253
contextIsolation: true,
2354
nodeIntegration: false,
2455
sandbox: false,
2556
},
2657
show: false,
58+
backgroundColor: '#0f0f1a',
2759
});
2860

29-
win.once('ready-to-show', () => {
30-
win.show();
31-
logger.info('Main window shown');
32-
});
61+
// 立即显示 loading 页面
62+
await win.loadURL(LOADING_HTML);
63+
win.show();
64+
logger.info('Loading screen shown');
3365

3466
return win;
3567
}
@@ -50,15 +82,7 @@ app.whenReady().then(async () => {
5082
logger.info(`Frontend loaded: ${frontendUrl}`);
5183
} catch (error) {
5284
logger.error('Failed to start services', error);
53-
// 显示错误页面而不是直接退出
54-
mainWindow.show();
55-
mainWindow.loadURL(`data:text/html,
56-
<html><body style="font-family:system-ui;padding:40px;background:#1a1a2e;color:#eee">
57-
<h1>QueryGPT failed to start</h1>
58-
<p>Check the logs for details:</p>
59-
<code style="color:#ff6b6b">${String(error).replace(/</g,'&lt;')}</code>
60-
<p style="margin-top:20px;color:#888">Logs: ~/.querygpt-desktop/logs/</p>
61-
</body></html>`);
85+
await mainWindow.loadURL(ERROR_HTML(error));
6286
}
6387

6488
app.on('activate', async () => {

apps/desktop/electron/process-manager.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export class ProcessManager {
6262
} else {
6363
cmd = `lsof -ti tcp:${port}`;
6464
}
65-
const result = execSync(cmd, { encoding: 'utf-8' }).trim();
65+
const result = execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim();
6666
if (result) {
6767
for (const pid of result.split('\n')) {
6868
const p = parseInt(pid.trim());
@@ -81,6 +81,24 @@ export class ProcessManager {
8181
} catch { /* no process on port */ }
8282
}
8383

84+
/** 首次启动时从打包资源复制预生成的数据库到用户数据目录 */
85+
private copyBundledDatabases(): void {
86+
const dataDir = path.join(this.userDataDir, 'data');
87+
// 打包资源中的 data/ 目录(由 PyInstaller 打包进 backend/_internal/data/)
88+
const bundledDataDir = path.join(this.getBackendOutDir(), '_internal', 'data');
89+
90+
for (const dbName of ['demo.db', 'querygpt.db']) {
91+
const dest = path.join(dataDir, dbName);
92+
if (fs.existsSync(dest)) continue;
93+
94+
const src = path.join(bundledDataDir, dbName);
95+
if (fs.existsSync(src)) {
96+
fs.copyFileSync(src, dest);
97+
this.logger.info(`Copied bundled ${dbName} to user data dir`);
98+
}
99+
}
100+
}
101+
84102
async startAll(): Promise<void> {
85103
fs.mkdirSync(this.userDataDir, { recursive: true });
86104
fs.mkdirSync(path.join(this.userDataDir, 'logs'), { recursive: true });
@@ -93,6 +111,9 @@ export class ProcessManager {
93111
this.logger.info('Created user .env from example');
94112
}
95113

114+
// 首次启动:从打包资源复制预生成的数据库到用户目录
115+
this.copyBundledDatabases();
116+
96117
// 清理可能残留的旧进程
97118
this.killPortProcess(BACKEND_PORT);
98119
this.killPortProcess(FRONTEND_PORT);

apps/desktop/scripts/build-pyinstaller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ async function main() {
111111
// 显式安装 aiosqlite(pydantic-settings 动态加载时需要)
112112
run(`${pip} install aiosqlite`);
113113

114+
// 2.5. 预生成 demo.db + querygpt.db(构建时生成,运行时直接复制到用户目录)
115+
console.log('\nPre-generating databases...');
116+
const seedScript = join(DESKTOP_DIR, 'scripts', 'seed-databases.py');
117+
run(`${python} "${seedScript}"`);
118+
114119
// 3. 收集可能动态导入的模块
115120
console.log('\n[3/5] Collecting dynamic imports...');
116121
const hiddenImports = [

0 commit comments

Comments
 (0)