mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(web): implement FetchAdapter and /_web/ internal API routes
Phase 3A.1: Create the HTTP bridge between the Vue SPA frontend and chatlab serve backend for the CLI serve Web version. Server side (packages/server): - Add /_web/ route group (routes/web.ts) with no auth requirement, covering: sessions CRUD, time range, member-activity/hourly/daily/ weekday/message-type stats, members CRUD+pagination+merge+history, SQL Lab execute+schema. - Skip auth hook for /_web/ prefix (same-origin internal API). - Add getTimeRange() to @openchatlab/core basic queries. Client side (src/adapters): - FetchAdapter wrapping all /_web/ endpoints with proper HTTP verbs, TimeFilter query param serialization, and error handling. - Unimplemented analysis methods (catchphrase, mention, cluster, etc.) throw descriptive errors for progressive enhancement. - Wire FetchAdapter into adapter factory (non-Electron, non-browser).
This commit is contained in:
1 parent
9ac4276465
commit
086bd882a7
8 files changed
+685
-15
No files matched your search
@@ -23,6 +23,7 @@ export {
|
||||
getSessionMeta,
|
||||
getSessionOverview,
|
||||
getDatabaseSchema,
|
||||
getTimeRange,
|
||||
getAvailableYears,
|
||||
getMemberActivity,
|
||||
getHourlyActivity,
|
||||
|
||||
@@ -38,6 +38,17 @@ export interface MessageTypeStats {
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息时间范围
|
||||
*/
|
||||
export function getTimeRange(db: DatabaseAdapter): { start: number; end: number } | null {
|
||||
const row = db.prepare('SELECT MIN(ts) as start, MAX(ts) as end FROM message').get() as
|
||||
| { start: number | null; end: number | null }
|
||||
| undefined
|
||||
if (!row || row.start == null || row.end == null) return null
|
||||
return { start: row.start, end: row.end }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的年份列表
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
export { buildTimeFilter, buildSystemMessageFilter } from './filters'
|
||||
|
||||
export {
|
||||
isChatSessionDb,
|
||||
getSessionMeta,
|
||||
getSessionOverview,
|
||||
getDatabaseSchema,
|
||||
} from './session-queries'
|
||||
export { isChatSessionDb, getSessionMeta, getSessionOverview, getDatabaseSchema } from './session-queries'
|
||||
export type { SessionMeta, SessionOverview, SessionInfo } from './session-queries'
|
||||
|
||||
export {
|
||||
getTimeRange,
|
||||
getAvailableYears,
|
||||
getMemberActivity,
|
||||
getHourlyActivity,
|
||||
@@ -16,13 +12,7 @@ export {
|
||||
getWeekdayActivity,
|
||||
getMessageTypeStats,
|
||||
} from './basic-queries'
|
||||
export type {
|
||||
MemberActivity,
|
||||
HourlyActivity,
|
||||
DailyActivity,
|
||||
WeekdayActivity,
|
||||
MessageTypeStats,
|
||||
} from './basic-queries'
|
||||
export type { MemberActivity, HourlyActivity, DailyActivity, WeekdayActivity, MessageTypeStats } from './basic-queries'
|
||||
|
||||
export {
|
||||
queryMessages,
|
||||
|
||||
@@ -27,6 +27,9 @@ function safeTokenCompare(a: string, b: string): boolean {
|
||||
export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (!cachedToken) return
|
||||
|
||||
// /_web/ 内部 API 不需要认证(仅限同源 Web UI 使用)
|
||||
if (request.url.startsWith('/_web/')) return
|
||||
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
const err = unauthorized()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createServer } from './server'
|
||||
import { setAuthToken } from './auth'
|
||||
import { registerSystemRoutes } from './routes/system'
|
||||
import { registerSessionRoutes } from './routes/sessions'
|
||||
import { registerWebRoutes } from './routes/web'
|
||||
|
||||
let server: FastifyInstance | null = null
|
||||
let dbManager: DatabaseManager | null = null
|
||||
@@ -85,6 +86,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
server = createServer()
|
||||
registerSystemRoutes(server, dbManager)
|
||||
registerSessionRoutes(server, dbManager)
|
||||
registerWebRoutes(server, dbManager)
|
||||
|
||||
await server.listen({ port, host })
|
||||
|
||||
@@ -111,3 +113,4 @@ export async function stopHttpServer(): Promise<void> {
|
||||
export { createServer } from './server'
|
||||
export { registerSystemRoutes } from './routes/system'
|
||||
export { registerSessionRoutes } from './routes/sessions'
|
||||
export { registerWebRoutes } from './routes/web'
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* ChatLab Internal Web API — /_web/ routes
|
||||
*
|
||||
* 供 CLI serve Web 前端使用的内部 API(无认证、UI 友好的响应格式)。
|
||||
* 数据格式直接对齐 QueryAdapter 接口,避免前端二次转换。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { DatabaseManager } from '@openchatlab/node-runtime'
|
||||
import type { TimeFilter } from '@openchatlab/shared-types'
|
||||
import {
|
||||
getSessionMeta,
|
||||
getSessionOverview,
|
||||
getTimeRange,
|
||||
getAvailableYears,
|
||||
getMemberActivity,
|
||||
getHourlyActivity,
|
||||
getDailyActivity,
|
||||
getWeekdayActivity,
|
||||
getMessageTypeStats,
|
||||
getDatabaseSchema,
|
||||
executeReadonlySql,
|
||||
} from '@openchatlab/core'
|
||||
|
||||
function ensureDb(dbManager: DatabaseManager, sessionId: string) {
|
||||
const db = dbManager.open(sessionId)
|
||||
if (!db) {
|
||||
const err = new Error(`Session not found: ${sessionId}`)
|
||||
;(err as any).statusCode = 404
|
||||
throw err
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
function parseTimeFilter(query: Record<string, string | undefined>): TimeFilter | undefined {
|
||||
const { startTs, endTs, memberId } = query
|
||||
if (!startTs && !endTs && !memberId) return undefined
|
||||
const filter: TimeFilter = {}
|
||||
if (startTs) filter.startTs = parseInt(startTs, 10)
|
||||
if (endTs) filter.endTs = parseInt(endTs, 10)
|
||||
if (memberId) filter.memberId = parseInt(memberId, 10)
|
||||
return filter
|
||||
}
|
||||
|
||||
export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseManager): void {
|
||||
// ==================== 会话管理 ====================
|
||||
|
||||
server.get('/_web/sessions', async () => {
|
||||
const sessionIds = dbManager.listSessionIds()
|
||||
return sessionIds
|
||||
.map((id) => {
|
||||
const db = dbManager.open(id)
|
||||
if (!db) return null
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) return null
|
||||
const overview = getSessionOverview(db)
|
||||
return {
|
||||
id,
|
||||
name: meta.name,
|
||||
platform: meta.platform,
|
||||
type: meta.type,
|
||||
importedAt: meta.importedAt ?? 0,
|
||||
messageCount: overview.totalMessages,
|
||||
memberCount: overview.totalMembers,
|
||||
dbPath: '',
|
||||
groupId: meta.groupId ?? null,
|
||||
groupAvatar: meta.groupAvatar ?? null,
|
||||
ownerId: meta.ownerId ?? null,
|
||||
memberAvatar: null,
|
||||
lastMessageTs: overview.lastMessageTs ?? null,
|
||||
summaryCount: 0,
|
||||
aiConversationCount: 0,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) return null
|
||||
const overview = getSessionOverview(db)
|
||||
return {
|
||||
id: request.params.id,
|
||||
name: meta.name,
|
||||
platform: meta.platform,
|
||||
type: meta.type,
|
||||
importedAt: meta.importedAt ?? 0,
|
||||
messageCount: overview.totalMessages,
|
||||
memberCount: overview.totalMembers,
|
||||
dbPath: '',
|
||||
groupId: meta.groupId ?? null,
|
||||
groupAvatar: meta.groupAvatar ?? null,
|
||||
ownerId: meta.ownerId ?? null,
|
||||
memberAvatar: null,
|
||||
lastMessageTs: overview.lastMessageTs ?? null,
|
||||
summaryCount: 0,
|
||||
aiConversationCount: 0,
|
||||
}
|
||||
})
|
||||
|
||||
server.delete<{ Params: { id: string } }>('/_web/sessions/:id', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
dbManager.close(id)
|
||||
const dbPath = dbManager.getDbPath(id)
|
||||
const fs = await import('fs')
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.unlinkSync(dbPath)
|
||||
return { success: true }
|
||||
}
|
||||
return reply.code(404).send({ success: false, error: 'File not found' })
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ success: false, error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
server.patch<{ Params: { id: string }; Body: { name: string } }>('/_web/sessions/:id/name', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const { name } = request.body
|
||||
db.prepare('UPDATE meta SET name = ?').run(name)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
server.patch<{ Params: { id: string }; Body: { ownerId: string | null } }>(
|
||||
'/_web/sessions/:id/owner',
|
||||
async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const { ownerId } = request.body
|
||||
db.prepare('UPDATE meta SET owner_id = ?').run(ownerId ?? null)
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== 时间范围 ====================
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id/years', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
return getAvailableYears(db)
|
||||
})
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id/time-range', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
return getTimeRange(db)
|
||||
})
|
||||
|
||||
// ==================== 统计分析 ====================
|
||||
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { startTs?: string; endTs?: string; memberId?: string }
|
||||
}>('/_web/sessions/:id/stats/member-activity', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return getMemberActivity(db, filter)
|
||||
})
|
||||
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { startTs?: string; endTs?: string; memberId?: string }
|
||||
}>('/_web/sessions/:id/stats/hourly', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return getHourlyActivity(db, filter)
|
||||
})
|
||||
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { startTs?: string; endTs?: string; memberId?: string }
|
||||
}>('/_web/sessions/:id/stats/daily', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return getDailyActivity(db, filter)
|
||||
})
|
||||
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { startTs?: string; endTs?: string; memberId?: string }
|
||||
}>('/_web/sessions/:id/stats/weekday', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return getWeekdayActivity(db, filter)
|
||||
})
|
||||
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { startTs?: string; endTs?: string; memberId?: string }
|
||||
}>('/_web/sessions/:id/stats/message-types', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return getMessageTypeStats(db, filter)
|
||||
})
|
||||
|
||||
// ==================== 成员管理 ====================
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id/members', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
m.id, m.platform_id as platformId,
|
||||
m.account_name as accountName,
|
||||
m.group_nickname as groupNickname,
|
||||
m.aliases, m.avatar,
|
||||
(SELECT COUNT(*) FROM message WHERE sender_id = m.id) as messageCount
|
||||
FROM member m
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'
|
||||
ORDER BY messageCount DESC`
|
||||
)
|
||||
.all() as any[]
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
aliases: r.aliases ? (typeof r.aliases === 'string' ? JSON.parse(r.aliases) : r.aliases) : [],
|
||||
}))
|
||||
})
|
||||
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { page?: string; pageSize?: string; search?: string; sortOrder?: string }
|
||||
}>('/_web/sessions/:id/members/paginated', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const page = Math.max(1, parseInt(request.query.page || '1', 10))
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(request.query.pageSize || '20', 10)))
|
||||
const search = request.query.search?.trim() || ''
|
||||
const sortOrder = request.query.sortOrder === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
let whereClause = ''
|
||||
const params: unknown[] = []
|
||||
if (search) {
|
||||
whereClause = `WHERE (m.account_name LIKE ? OR m.group_nickname LIKE ? OR m.platform_id LIKE ? OR m.aliases LIKE ?)`
|
||||
const searchParam = `%${search}%`
|
||||
params.push(searchParam, searchParam, searchParam, searchParam)
|
||||
}
|
||||
|
||||
const countRow = db.prepare(`SELECT COUNT(*) as total FROM member m ${whereClause}`).get(...params) as {
|
||||
total: number
|
||||
}
|
||||
const total = countRow.total
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
m.id, m.platform_id as platformId,
|
||||
m.account_name as accountName,
|
||||
m.group_nickname as groupNickname,
|
||||
m.aliases, m.avatar,
|
||||
COUNT(msg.id) as messageCount
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
${whereClause}
|
||||
GROUP BY m.id
|
||||
ORDER BY messageCount ${sortOrder}
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...params, pageSize, offset) as any[]
|
||||
|
||||
return {
|
||||
items: rows.map((r: any) => ({
|
||||
...r,
|
||||
aliases: r.aliases ? (typeof r.aliases === 'string' ? JSON.parse(r.aliases) : r.aliases) : [],
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
}
|
||||
})
|
||||
|
||||
server.patch<{ Params: { id: string; memberId: string }; Body: { aliases: string[] } }>(
|
||||
'/_web/sessions/:id/members/:memberId/aliases',
|
||||
async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const memberId = parseInt(request.params.memberId, 10)
|
||||
const { aliases } = request.body
|
||||
db.prepare('UPDATE member SET aliases = ? WHERE id = ?').run(JSON.stringify(aliases), memberId)
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
server.delete<{ Params: { id: string; memberId: string } }>(
|
||||
'/_web/sessions/:id/members/:memberId',
|
||||
async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const memberId = parseInt(request.params.memberId, 10)
|
||||
db.prepare('DELETE FROM message WHERE sender_id = ?').run(memberId)
|
||||
db.prepare('DELETE FROM member WHERE id = ?').run(memberId)
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
server.post<{ Params: { id: string }; Body: { memberId1: number; memberId2: number } }>(
|
||||
'/_web/sessions/:id/members/merge',
|
||||
async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const { memberId1, memberId2 } = request.body
|
||||
db.prepare('UPDATE message SET sender_id = ? WHERE sender_id = ?').run(memberId1, memberId2)
|
||||
db.prepare('DELETE FROM member WHERE id = ?').run(memberId2)
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string; memberId: string } }>(
|
||||
'/_web/sessions/:id/members/:memberId/history',
|
||||
async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const memberId = parseInt(request.params.memberId, 10)
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
sender_account_name as accountName,
|
||||
sender_group_nickname as groupNickname,
|
||||
MIN(ts) as startTs,
|
||||
MAX(ts) as endTs
|
||||
FROM message
|
||||
WHERE sender_id = ?
|
||||
GROUP BY sender_account_name, sender_group_nickname
|
||||
ORDER BY startTs`
|
||||
)
|
||||
.all(memberId) as any[]
|
||||
|
||||
const history: Array<{ nameType: string; name: string; startTs: number; endTs: number | null }> = []
|
||||
for (const row of rows) {
|
||||
if (row.accountName) {
|
||||
history.push({ nameType: 'account_name', name: row.accountName, startTs: row.startTs, endTs: row.endTs })
|
||||
}
|
||||
if (row.groupNickname) {
|
||||
history.push({ nameType: 'group_nickname', name: row.groupNickname, startTs: row.startTs, endTs: row.endTs })
|
||||
}
|
||||
}
|
||||
return history
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== SQL Lab ====================
|
||||
|
||||
server.post<{ Params: { id: string }; Body: { sql: string } }>('/_web/sessions/:id/sql', async (request, reply) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const { sql } = request.body || {}
|
||||
if (!sql || typeof sql !== 'string') {
|
||||
return reply.code(400).send({ error: 'Missing sql parameter' })
|
||||
}
|
||||
try {
|
||||
return executeReadonlySql(db, sql)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'SQL execution error'
|
||||
return reply.code(400).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id/schema', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const tables = getDatabaseSchema(db)
|
||||
return tables.map((t) => {
|
||||
const columns = db.prepare(`PRAGMA table_info('${t.name}')`).all() as Array<{
|
||||
name: string
|
||||
type: string
|
||||
notnull: number
|
||||
pk: number
|
||||
}>
|
||||
return {
|
||||
name: t.name,
|
||||
columns: columns.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
notnull: c.notnull === 1,
|
||||
pk: c.pk === 1,
|
||||
})),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* FetchAdapter — 通过 HTTP 调用 /_web/ 内部 API
|
||||
*
|
||||
* 用于 CLI serve Web 场景:前端 SPA 通过 fetch 访问 chatlab serve 后端。
|
||||
* 所有请求发往同源 /_web/ 前缀,无需 token 认证。
|
||||
*/
|
||||
|
||||
import type {
|
||||
QueryAdapter,
|
||||
AdapterCapabilities,
|
||||
SQLResult,
|
||||
TableSchema,
|
||||
PaginationParams,
|
||||
PaginatedResult,
|
||||
MentionGraphData,
|
||||
MessageLengthDistribution,
|
||||
} from './types'
|
||||
import type { AnalysisSession, MessageType } from '@/types/base'
|
||||
import type { TimeFilter } from '@openchatlab/shared-types'
|
||||
import type {
|
||||
MemberActivity,
|
||||
MemberWithStats,
|
||||
MemberNameHistory,
|
||||
HourlyActivity,
|
||||
DailyActivity,
|
||||
WeekdayActivity,
|
||||
MonthlyActivity,
|
||||
CatchphraseAnalysis,
|
||||
MentionAnalysis,
|
||||
LaughAnalysis,
|
||||
ClusterGraphData,
|
||||
ClusterGraphOptions,
|
||||
RelationshipStats,
|
||||
} from '@/types/analysis'
|
||||
import type { LanguagePreferenceResult } from '@/types/quotes/languagePreference'
|
||||
|
||||
function buildFilterParams(filter?: TimeFilter): string {
|
||||
if (!filter) return ''
|
||||
const params = new URLSearchParams()
|
||||
if (filter.startTs) params.set('startTs', String(filter.startTs))
|
||||
if (filter.endTs) params.set('endTs', String(filter.endTs))
|
||||
if (filter.memberId) params.set('memberId', String(filter.memberId))
|
||||
const qs = params.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
async function get<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${body}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function post<T>(url: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function patch<T>(url: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function del<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
function notImplemented(method: string): never {
|
||||
throw new Error(
|
||||
`[FetchAdapter] ${method} is not yet implemented on the server side. ` +
|
||||
'This feature requires additional backend query support.'
|
||||
)
|
||||
}
|
||||
|
||||
export class FetchAdapter implements QueryAdapter {
|
||||
getCapabilities(): AdapterCapabilities {
|
||||
return {
|
||||
ai: false,
|
||||
nlp: false,
|
||||
fts: true,
|
||||
nativeFileDialog: false,
|
||||
osIntegration: false,
|
||||
demoImport: false,
|
||||
plugin: false,
|
||||
migration: false,
|
||||
merge: false,
|
||||
incrementalImport: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 会话管理 ====================
|
||||
|
||||
getSessions(): Promise<AnalysisSession[]> {
|
||||
return get('/_web/sessions')
|
||||
}
|
||||
|
||||
getSession(sessionId: string): Promise<AnalysisSession | null> {
|
||||
return get(`/_web/sessions/${sessionId}`)
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
const result = await del<{ success: boolean }>(`/_web/sessions/${sessionId}`)
|
||||
return result.success
|
||||
}
|
||||
|
||||
async renameSession(sessionId: string, newName: string): Promise<boolean> {
|
||||
const result = await patch<{ success: boolean }>(`/_web/sessions/${sessionId}/name`, {
|
||||
name: newName,
|
||||
})
|
||||
return result.success
|
||||
}
|
||||
|
||||
async updateSessionOwnerId(sessionId: string, ownerId: string | null): Promise<boolean> {
|
||||
const result = await patch<{ success: boolean }>(`/_web/sessions/${sessionId}/owner`, {
|
||||
ownerId,
|
||||
})
|
||||
return result.success
|
||||
}
|
||||
|
||||
// ==================== 时间范围 ====================
|
||||
|
||||
getAvailableYears(sessionId: string): Promise<number[]> {
|
||||
return get(`/_web/sessions/${sessionId}/years`)
|
||||
}
|
||||
|
||||
getTimeRange(sessionId: string): Promise<{ start: number; end: number } | null> {
|
||||
return get(`/_web/sessions/${sessionId}/time-range`)
|
||||
}
|
||||
|
||||
// ==================== 统计分析 ====================
|
||||
|
||||
getMemberActivity(sessionId: string, filter?: TimeFilter): Promise<MemberActivity[]> {
|
||||
return get(`/_web/sessions/${sessionId}/stats/member-activity${buildFilterParams(filter)}`)
|
||||
}
|
||||
|
||||
getHourlyActivity(sessionId: string, filter?: TimeFilter): Promise<HourlyActivity[]> {
|
||||
return get(`/_web/sessions/${sessionId}/stats/hourly${buildFilterParams(filter)}`)
|
||||
}
|
||||
|
||||
getDailyActivity(sessionId: string, filter?: TimeFilter): Promise<DailyActivity[]> {
|
||||
return get(`/_web/sessions/${sessionId}/stats/daily${buildFilterParams(filter)}`)
|
||||
}
|
||||
|
||||
getWeekdayActivity(sessionId: string, filter?: TimeFilter): Promise<WeekdayActivity[]> {
|
||||
return get(`/_web/sessions/${sessionId}/stats/weekday${buildFilterParams(filter)}`)
|
||||
}
|
||||
|
||||
getMonthlyActivity(_sessionId: string, _filter?: TimeFilter): Promise<MonthlyActivity[]> {
|
||||
notImplemented('getMonthlyActivity')
|
||||
}
|
||||
|
||||
getYearlyActivity(_sessionId: string, _filter?: TimeFilter): Promise<Array<{ year: number; messageCount: number }>> {
|
||||
notImplemented('getYearlyActivity')
|
||||
}
|
||||
|
||||
getMessageLengthDistribution(_sessionId: string, _filter?: TimeFilter): Promise<MessageLengthDistribution> {
|
||||
notImplemented('getMessageLengthDistribution')
|
||||
}
|
||||
|
||||
getMessageTypeDistribution(
|
||||
sessionId: string,
|
||||
filter?: TimeFilter
|
||||
): Promise<Array<{ type: MessageType; count: number }>> {
|
||||
return get(`/_web/sessions/${sessionId}/stats/message-types${buildFilterParams(filter)}`)
|
||||
}
|
||||
|
||||
// ==================== 成员管理 ====================
|
||||
|
||||
getMembers(sessionId: string): Promise<MemberWithStats[]> {
|
||||
return get(`/_web/sessions/${sessionId}/members`)
|
||||
}
|
||||
|
||||
getMembersPaginated(sessionId: string, params: PaginationParams): Promise<PaginatedResult<MemberWithStats>> {
|
||||
const qs = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
if (params.search) qs.set('search', params.search)
|
||||
if (params.sortOrder) qs.set('sortOrder', params.sortOrder)
|
||||
return get(`/_web/sessions/${sessionId}/members/paginated?${qs}`)
|
||||
}
|
||||
|
||||
getMemberNameHistory(sessionId: string, memberId: number): Promise<MemberNameHistory[]> {
|
||||
return get(`/_web/sessions/${sessionId}/members/${memberId}/history`)
|
||||
}
|
||||
|
||||
async updateMemberAliases(sessionId: string, memberId: number, aliases: string[]): Promise<boolean> {
|
||||
const result = await patch<{ success: boolean }>(`/_web/sessions/${sessionId}/members/${memberId}/aliases`, {
|
||||
aliases,
|
||||
})
|
||||
return result.success
|
||||
}
|
||||
|
||||
async mergeMembers(sessionId: string, memberId1: number, memberId2: number): Promise<boolean> {
|
||||
const result = await post<{ success: boolean }>(`/_web/sessions/${sessionId}/members/merge`, {
|
||||
memberId1,
|
||||
memberId2,
|
||||
})
|
||||
return result.success
|
||||
}
|
||||
|
||||
async deleteMember(sessionId: string, memberId: number): Promise<boolean> {
|
||||
const result = await del<{ success: boolean }>(`/_web/sessions/${sessionId}/members/${memberId}`)
|
||||
return result.success
|
||||
}
|
||||
|
||||
// ==================== 社交分析(渐进实现) ====================
|
||||
|
||||
getCatchphraseAnalysis(_sessionId: string, _filter?: TimeFilter): Promise<CatchphraseAnalysis> {
|
||||
notImplemented('getCatchphraseAnalysis')
|
||||
}
|
||||
|
||||
getLanguagePreferenceAnalysis(
|
||||
_sessionId: string,
|
||||
_locale: string,
|
||||
_filter?: TimeFilter,
|
||||
_dictType?: string
|
||||
): Promise<LanguagePreferenceResult> {
|
||||
notImplemented('getLanguagePreferenceAnalysis')
|
||||
}
|
||||
|
||||
getMentionAnalysis(_sessionId: string, _filter?: TimeFilter): Promise<MentionAnalysis> {
|
||||
notImplemented('getMentionAnalysis')
|
||||
}
|
||||
|
||||
getMentionGraph(_sessionId: string, _filter?: TimeFilter): Promise<MentionGraphData> {
|
||||
notImplemented('getMentionGraph')
|
||||
}
|
||||
|
||||
getClusterGraph(_sessionId: string, _filter?: TimeFilter, _options?: ClusterGraphOptions): Promise<ClusterGraphData> {
|
||||
notImplemented('getClusterGraph')
|
||||
}
|
||||
|
||||
getLaughAnalysis(_sessionId: string, _filter?: TimeFilter, _keywords?: string[]): Promise<LaughAnalysis> {
|
||||
notImplemented('getLaughAnalysis')
|
||||
}
|
||||
|
||||
getRelationshipStats(
|
||||
_sessionId: string,
|
||||
_filter?: TimeFilter,
|
||||
_options?: { perseveranceThreshold?: number }
|
||||
): Promise<RelationshipStats> {
|
||||
notImplemented('getRelationshipStats')
|
||||
}
|
||||
|
||||
// ==================== SQL Lab ====================
|
||||
|
||||
executeSQL(sessionId: string, sql: string): Promise<SQLResult> {
|
||||
return post(`/_web/sessions/${sessionId}/sql`, { sql })
|
||||
}
|
||||
|
||||
getSchema(sessionId: string): Promise<TableSchema[]> {
|
||||
return get(`/_web/sessions/${sessionId}/schema`)
|
||||
}
|
||||
|
||||
// ==================== 插件系统(不支持) ====================
|
||||
|
||||
pluginQuery<T = Record<string, unknown>>(_sessionId: string, _sql: string, _params?: unknown[]): Promise<T[]> {
|
||||
notImplemented('pluginQuery')
|
||||
}
|
||||
|
||||
pluginCompute<T = unknown>(_fnString: string, _input: unknown): Promise<T> {
|
||||
notImplemented('pluginCompute')
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ export async function initAdapter(): Promise<QueryAdapter> {
|
||||
// Phase 3B: BrowserSqlAdapter
|
||||
throw new Error('BrowserSqlAdapter is not yet implemented.')
|
||||
} else {
|
||||
// Phase 3A: FetchAdapter
|
||||
throw new Error('FetchAdapter is not yet implemented.')
|
||||
const { FetchAdapter } = await import('./fetch')
|
||||
_adapter = new FetchAdapter()
|
||||
}
|
||||
|
||||
return _adapter!
|
||||
|
||||
Reference in new issue
Block a user