fix(web): session index, CORS proxy, demo guard, and dev script improvements

- Add session index endpoints (generate-index, clear-index) with writable db
- Add window.sessionApi shims for web mode (getStats, generate, getSessions, etc.)
- Fix CORS: add Vite dev proxy for chatlab.fun, centralize CHATLAB_SITE_BASE
- Guard DemoImportButton: show fallback message in web mode
- Use tsx watch for dev:serve hot-reloading
- Reorganize dev scripts: dev:app (web frontend), dev:serve (backend), dev:web (both)
This commit is contained in:
digua
2026-05-13 21:51:27 +08:00
parent 4053b7c77f
commit 700b4266f3
10 changed files with 212 additions and 11 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"build:win": "pnpm run build && electron-builder --win --config electron-builder.yml -p never",
"build:linux": "pnpm run build && electron-builder --linux --config electron-builder.yml -p never",
"dev:app": "vite --config vite.web.config.mts",
"dev:serve": "tsx packages/server/src/cli.ts serve",
"dev:serve": "tsx watch packages/server/src/cli.ts serve",
"dev:web": "CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts",
"build:web": "vite build --config vite.web.config.mts",
"type-check:web": "vue-tsc --noEmit -p tsconfig.web.json",
+64
View File
@@ -41,6 +41,15 @@ function ensureDb(dbManager: DatabaseManager, sessionId: string) {
return db
}
function ensureWritableDb(dbManager: DatabaseManager, sessionId: string) {
dbManager.close(sessionId)
const db = dbManager.open(sessionId, { readonly: false })
if (!db) {
throw Object.assign(new Error(`Session not found: ${sessionId}`), { statusCode: 404 })
}
return db
}
function parseTimeFilter(query: Record<string, string | undefined>): TimeFilter | undefined {
const { startTs, endTs, memberId } = query
if (!startTs && !endTs && !memberId) return undefined
@@ -406,6 +415,61 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa
return stmt.all(params)
})
// ==================== 会话索引 ====================
server.post<{
Params: { id: string }
Body: { gapThreshold?: number }
}>('/_web/sessions/:id/generate-index', async (request) => {
const db = ensureWritableDb(dbManager, request.params.id)
const gapThreshold = (request.body as any)?.gapThreshold ?? 1800
const messages = db.prepare('SELECT id, ts FROM message ORDER BY ts ASC').all() as Array<{ id: number; ts: number }>
if (messages.length === 0) return { sessionCount: 0 }
type SessionBound = { startTs: number; endTs: number; msgIds: number[] }
const sessions: SessionBound[] = []
let current: SessionBound = { startTs: messages[0].ts, endTs: messages[0].ts, msgIds: [messages[0].id] }
for (let i = 1; i < messages.length; i++) {
const gap = messages[i].ts - messages[i - 1].ts
if (gap > gapThreshold) {
sessions.push(current)
current = { startTs: messages[i].ts, endTs: messages[i].ts, msgIds: [messages[i].id] }
} else {
current.endTs = messages[i].ts
current.msgIds.push(messages[i].id)
}
}
sessions.push(current)
const insertSession = db.prepare('INSERT INTO chat_session (start_ts, end_ts, message_count) VALUES (?, ?, ?)')
const insertCtx = db.prepare('INSERT OR REPLACE INTO message_context (message_id, session_id) VALUES (?, ?)')
db.transaction(() => {
db.prepare('DELETE FROM chat_session').run()
db.prepare('DELETE FROM message_context').run()
for (const s of sessions) {
const info = insertSession.run(s.startTs, s.endTs, s.msgIds.length)
const rowId = info.lastInsertRowid
for (const msgId of s.msgIds) {
insertCtx.run(msgId, rowId)
}
}
})
return { sessionCount: sessions.length }
})
server.post<{ Params: { id: string } }>('/_web/sessions/:id/clear-index', async (request) => {
const db = ensureWritableDb(dbManager, request.params.id)
db.prepare('DELETE FROM chat_session').run()
db.prepare('DELETE FROM message_context').run()
return { success: true }
})
// ==================== 导入管线 ====================
server.get('/_web/supported-formats', async () => {
+119 -1
View File
@@ -175,8 +175,105 @@ async function getAllRecentMessages(
return { messages: messages.reverse(), total }
}
// ==================== window.sessionApi 垫片 ====================
async function sessionGetStats(
adapter: QueryAdapter,
sessionId: string
): Promise<{ hasIndex: boolean; sessionCount: number; gapThreshold: number }> {
try {
const rows = await pq<{ cnt: number }>(adapter, sessionId, 'SELECT COUNT(*) as cnt FROM chat_session', [])
const count = rows[0]?.cnt ?? 0
return { hasIndex: count > 0, sessionCount: count, gapThreshold: 1800 }
} catch {
return { hasIndex: false, sessionCount: 0, gapThreshold: 1800 }
}
}
async function sessionGenerate(adapter: QueryAdapter, sessionId: string, gapThreshold: number = 1800): Promise<number> {
const messages = await pq<{ id: number; ts: number }>(
adapter,
sessionId,
'SELECT id, ts FROM message ORDER BY ts ASC',
[]
)
if (messages.length === 0) return 0
type Session = { startTs: number; endTs: number; count: number }
const sessions: Session[] = []
let current: Session = { startTs: messages[0].ts, endTs: messages[0].ts, count: 1 }
for (let i = 1; i < messages.length; i++) {
const gap = messages[i].ts - messages[i - 1].ts
if (gap > gapThreshold) {
sessions.push(current)
current = { startTs: messages[i].ts, endTs: messages[i].ts, count: 1 }
} else {
current.endTs = messages[i].ts
current.count++
}
}
sessions.push(current)
// 写入数据库: 先清空再插入(通过后端 pluginQuery 只支持只读,需要后端端点)
// 这里通过 FetchAdapter 调用后端的 generate 端点
const resp = await fetch(`/_web/sessions/${sessionId}/generate-index`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gapThreshold }),
})
if (!resp.ok) throw new Error(`Failed to generate session index: ${resp.status}`)
const result = (await resp.json()) as { sessionCount: number }
return result.sessionCount
}
async function sessionGetSessions(
adapter: QueryAdapter,
sessionId: string
): Promise<Array<{ id: number; startTs: number; endTs: number; messageCount: number; summary: string | null }>> {
return pq(
adapter,
sessionId,
'SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary FROM chat_session ORDER BY start_ts ASC',
[]
)
}
async function sessionHasIndex(adapter: QueryAdapter, sessionId: string): Promise<boolean> {
const stats = await sessionGetStats(adapter, sessionId)
return stats.hasIndex
}
async function sessionGetByTimeRange(
adapter: QueryAdapter,
sessionId: string,
startTs: number,
endTs: number
): Promise<Array<{ id: number; startTs: number; endTs: number; messageCount: number; summary: string | null }>> {
return pq(
adapter,
sessionId,
'SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary FROM chat_session WHERE start_ts >= ? AND end_ts <= ? ORDER BY start_ts ASC',
[startTs, endTs]
)
}
async function sessionGetRecent(
adapter: QueryAdapter,
sessionId: string,
limit: number
): Promise<Array<{ id: number; startTs: number; endTs: number; messageCount: number; summary: string | null }>> {
return pq(
adapter,
sessionId,
'SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary FROM chat_session ORDER BY start_ts DESC LIMIT ?',
[limit]
)
}
/**
* 注入 window.chatApi window.aiApi 垫片
* 注入 window.chatApi / window.aiApi / window.sessionApi 垫片
*/
export function installWebApiShims(adapter: QueryAdapter): void {
if (!window.chatApi) {
@@ -227,4 +324,25 @@ export function installWebApiShims(adapter: QueryAdapter): void {
) => searchMessages(adapter, sid, kw, filter, limit, offset, senderId)
aiApi.getAllRecentMessages = (sid: string, filter?: any, limit?: number) =>
getAllRecentMessages(adapter, sid, filter, limit)
// ===== window.sessionApi 垫片 =====
if (!(window as any).sessionApi) {
;(window as any).sessionApi = {}
}
const sessionApi = (window as any).sessionApi
sessionApi.getStats = (sid: string) => sessionGetStats(adapter, sid)
sessionApi.generate = (sid: string, gap?: number) => sessionGenerate(adapter, sid, gap)
sessionApi.hasIndex = (sid: string) => sessionHasIndex(adapter, sid)
sessionApi.clear = async (sid: string) => {
await fetch(`/_web/sessions/${sid}/clear-index`, { method: 'POST' })
return true
}
sessionApi.getSessions = (sid: string) => sessionGetSessions(adapter, sid)
sessionApi.getByTimeRange = (sid: string, start: number, end: number) =>
sessionGetByTimeRange(adapter, sid, start, end)
sessionApi.getRecent = (sid: string, limit: number) => sessionGetRecent(adapter, sid, limit)
sessionApi.updateGapThreshold = async () => true
sessionApi.generateSummary = async () => ({ success: false, error: 'Not available in web mode' })
sessionApi.generateSummaries = async () => ({ success: 0, failed: 0, skipped: 0 })
sessionApi.checkCanGenerateSummary = async () => ({})
}
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { getChatlabSiteLocalePath } from '@/utils/chatlabSiteLocale'
import { CHATLAB_SITE_BASE, getChatlabSiteLocalePath } from '@/utils/chatlabSiteLocale'
import { storeToRefs } from 'pinia'
import { useSettingsStore } from '@/stores/settings'
import { sanitizeSummary } from '@/utils/sanitizeSummary'
@@ -88,7 +88,7 @@ const changelogs = ref<ChangelogItem[]>([])
function getChangelogUrl(lang: string) {
const localePath = getChatlabSiteLocalePath(lang)
const langPath = localePath || 'en'
return `https://chatlab.fun/changelogs/${langPath}.json`
return `${CHATLAB_SITE_BASE}/changelogs/${langPath}.json`
}
// 从服务端获取 changelog 数据
@@ -6,6 +6,7 @@ import { useSessionStore } from '@/stores/session'
import { useSettingsStore } from '@/stores/settings'
import { getChatlabSiteLocalePath } from '@/utils/chatlabSiteLocale'
import { getAdapter } from '@/adapters'
import { IS_ELECTRON } from '@/utils/platform'
const { t } = useI18n()
const router = useRouter()
@@ -25,6 +26,11 @@ async function navigateToSession(sessionId: string) {
}
async function handleImport() {
if (!IS_ELECTRON) {
error.value = t('home.demo.notAvailableInWeb', 'Demo 导入功能暂仅在桌面端可用')
return
}
isImporting.value = true
error.value = null
stage.value = 'downloading'
+2 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getChatlabSiteLocalePath, getChatlabSiteLangQuery } from '@/utils/chatlabSiteLocale'
import { CHATLAB_SITE_BASE, getChatlabSiteLocalePath, getChatlabSiteLangQuery } from '@/utils/chatlabSiteLocale'
import { IS_ELECTRON } from '@/utils/platform'
const emit = defineEmits<{
@@ -11,12 +11,10 @@ const emit = defineEmits<{
const { t, locale } = useI18n()
// 配置 URL 根据语言动态获取
const CONFIG_BASE_URL = 'https://chatlab.fun'
const configUrl = computed(() => {
const localePath = getChatlabSiteLocalePath(locale.value)
const langPath = localePath ? `/${localePath}` : ''
return `${CONFIG_BASE_URL}${langPath}/config.json`
return `${CHATLAB_SITE_BASE}${langPath}/config.json`
})
// 存储 key 也根据语言区分
+2 -1
View File
@@ -7,7 +7,8 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { i18n } from '@/i18n'
const CLOUD_MARKET_BASE_URL = 'https://chatlab.fun'
import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale'
const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE
const LOCALE_PATH_MAP: Record<string, string> = { 'zh-CN': 'cn', 'zh-TW': 'cn', 'en-US': 'en', 'ja-JP': 'ja' }
export interface AssistantSummary {
+2 -1
View File
@@ -7,7 +7,8 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAssistantStore } from './assistant'
const CLOUD_MARKET_BASE_URL = 'https://chatlab.fun'
import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale'
const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE
const LOCALE_PATH_MAP: Record<string, string> = { 'zh-CN': 'cn', 'zh-TW': 'cn', 'en-US': 'en', 'ja-JP': 'ja' }
export interface SkillSummary {
+8
View File
@@ -1,3 +1,5 @@
import { IS_ELECTRON } from './platform'
const LOCALE_PATH_MAP: Record<string, string> = {
'en-US': 'en',
'zh-CN': 'cn',
@@ -5,6 +7,12 @@ const LOCALE_PATH_MAP: Record<string, string> = {
'ja-JP': 'ja',
}
/**
* chatlab.fun 的基础 URL。
* Electron 直接访问远程;Web 模式通过 Vite dev proxy 避免 CORS。
*/
export const CHATLAB_SITE_BASE = IS_ELECTRON ? 'https://chatlab.fun' : '/_proxy/chatlab.fun'
/**
* 将应用 locale 转为 chatlab.fun 站点的路径前缀。
*/
+6 -1
View File
@@ -50,7 +50,7 @@ function chatlabServePlugin(): Plugin {
}
const serverDir = resolve(__dirname, 'packages/server')
serverProcess = spawn('npx', ['tsx', 'src/cli.ts', 'serve', '--port', String(BACKEND_PORT)], {
serverProcess = spawn('npx', ['tsx', 'watch', 'src/cli.ts', 'serve', '--port', String(BACKEND_PORT)], {
cwd: serverDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
@@ -134,6 +134,11 @@ export default defineConfig({
proxy: {
'/_web': `http://localhost:${BACKEND_PORT}`,
'/api': `http://localhost:${BACKEND_PORT}`,
'/_proxy/chatlab.fun': {
target: 'https://chatlab.fun',
changeOrigin: true,
rewrite: (p: string) => p.replace(/^\/_proxy\/chatlab\.fun/, ''),
},
},
},
})