mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-27 17:30:23 +08:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => ({})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 也根据语言区分
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user