feat(sync): add option to delete imported chats when removing subscription

- Show confirmation dialog with two choices: keep data or delete imported chats
- Backend supports deleteData param across CLI routes and Electron IPC
- DataSourceManager.removeSession now returns the removed session info
- Add i18n strings for the delete confirmation dialog (zh-CN/en-US/ja-JP/zh-TW)
This commit is contained in:
digua authored and digua committed 2026-05-23 16:28:50 +08:00
1 parent 31fc9be1ed
commit 7d5c541933
12 files changed
+228 -50

No files matched your search

+2 -1
View File
@@ -15,6 +15,7 @@ import { registerAutomationRoutes } from './routes'
export interface SyncRouteContext {
dsManager: DataSourceManager
pullEngine: PullEngine
dbManager: DatabaseManager
serverInfo: { port: number; host: string; token: string }
}
@@ -49,7 +50,7 @@ export function initSync(
logger: syncLogger,
})
registerAutomationRoutes(server, { dsManager, pullEngine, serverInfo })
registerAutomationRoutes(server, { dsManager, pullEngine, dbManager, serverInfo })
initScheduler({
dsManager,
+16 -3
View File
@@ -15,7 +15,7 @@ import {
import type { SyncRouteContext } from './index'
export function registerAutomationRoutes(server: FastifyInstance, ctx: SyncRouteContext): void {
const { dsManager, pullEngine, serverInfo } = ctx
const { dsManager, pullEngine, dbManager, serverInfo } = ctx
// ==================== Config (read-only in CLI mode) ====================
@@ -85,10 +85,17 @@ export function registerAutomationRoutes(server: FastifyInstance, ctx: SyncRoute
server.delete<{
Params: { id: string; sessId: string }
Querystring: { deleteData?: string }
}>('/_web/automation/data-sources/:id/sessions/:sessId', async (request, reply) => {
const ok = dsManager.removeSession(request.params.id, request.params.sessId)
if (!ok) return reply.code(404).send({ error: 'Session not found' })
const removed = dsManager.removeSession(request.params.id, request.params.sessId)
if (!removed) return reply.code(404).send({ error: 'Session not found' })
reloadTimer(request.params.id)
if (request.query.deleteData === 'true' && removed.targetSessionId) {
dbManager.close(removed.targetSessionId)
const dbPath = dbManager.getDbPath(removed.targetSessionId)
const fs = await import('node:fs')
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath)
}
return { success: true }
})
@@ -105,6 +112,12 @@ export function registerAutomationRoutes(server: FastifyInstance, ctx: SyncRoute
return pullEngine.triggerPullAll(request.params.id)
})
// ==================== Sync Progress ====================
server.get('/_web/automation/sync-progress', async () => {
return pullEngine.getProgress()
})
// ==================== Remote Session Discovery ====================
server.get<{
+8 -3
View File
@@ -11,6 +11,7 @@ import { setConfigManager } from '../api'
import { getSettingsDir } from '../paths'
import { apiLogger } from '../api/logger'
import { getImportingStatus } from '../api/routes/import'
import { deleteSession as deleteSessionFile } from '../database/core'
import { ElectronFetcher, WorkerImporter, BrowserWindowNotifier } from '../api/adapters'
import {
ConfigManager,
@@ -185,10 +186,14 @@ export function registerApiHandlers(_ctx: IpcContext): void {
}
)
ipcMain.handle('api:removeImportSession', (_event, sourceId: string, sessionId: string) => {
const result = dsManager.removeSession(sourceId, sessionId)
ipcMain.handle('api:removeImportSession', (_event, sourceId: string, sessionId: string, deleteData?: boolean) => {
const removed = dsManager.removeSession(sourceId, sessionId)
if (!removed) return false
reloadTimer(sourceId)
return result
if (deleteData && removed.targetSessionId) {
deleteSessionFile(removed.targetSessionId)
}
return true
})
// ==================== Sync ====================
+2 -2
View File
@@ -126,8 +126,8 @@ export const apiServerApi = {
return ipcRenderer.invoke('api:addImportSessions', sourceId, sessions)
},
removeImportSession: (sourceId: string, sessionId: string): Promise<boolean> => {
return ipcRenderer.invoke('api:removeImportSession', sourceId, sessionId)
removeImportSession: (sourceId: string, sessionId: string, deleteData?: boolean): Promise<boolean> => {
return ipcRenderer.invoke('api:removeImportSession', sourceId, sessionId, deleteData)
},
// ==================== 同步 ====================
+1 -1
View File
@@ -1074,7 +1074,7 @@ interface ApiServerApi {
sourceId: string,
sessions: Array<{ name: string; remoteSessionId: string }>
) => Promise<ImportSession[]>
removeImportSession: (sourceId: string, sessionId: string) => Promise<boolean>
removeImportSession: (sourceId: string, sessionId: string, deleteData?: boolean) => Promise<boolean>
triggerPull: (sourceId: string, sessionId?: string) => Promise<{ success: boolean; error?: string }>
triggerPullAll: (sourceId: string) => Promise<{ success: boolean; error?: string }>
fetchRemoteSessions: (
+5 -5
View File
@@ -166,15 +166,15 @@ export class DataSourceManager {
return added
}
removeSession(sourceId: string, sessionId: string): boolean {
removeSession(sourceId: string, sessionId: string): ImportSession | null {
const sources = this.loadAll()
const ds = sources.find((s) => s.id === sourceId)
if (!ds) return false
const before = ds.sessions.length
if (!ds) return null
const removed = ds.sessions.find((s) => s.id === sessionId)
if (!removed) return null
ds.sessions = ds.sessions.filter((s) => s.id !== sessionId)
if (ds.sessions.length === before) return false
this.saveAll(sources)
return true
return removed
}
updateSession(sourceId: string, sessionId: string, updates: Partial<ImportSession>): ImportSession | null {
@@ -10,7 +10,7 @@ import McpSettingsTab from './McpSettingsTab.vue'
const { t, locale } = useI18n()
const store = useApiServerStore()
const { config, status, loading, isRunning, hasError, isPortInUse, dataSources, pullingId, isWebMode } =
const { config, status, loading, isRunning, hasError, isPortInUse, dataSources, pullingIds, syncProgress, isWebMode } =
storeToRefs(store)
const activeSubTab = ref('sync')
@@ -45,6 +45,8 @@ const showManageModal = ref(false)
const managingDataSource = ref<DataSource | null>(null)
const showDeleteModal = ref(false)
const deletingDataSource = ref<DataSource | null>(null)
const showDeleteSessionModal = ref(false)
const deletingSession = ref<{ ds: DataSource; sess: ImportSession } | null>(null)
let unlistenStartupError: (() => void) | null = null
let unlistenPullResult: (() => void) | null = null
@@ -175,8 +177,25 @@ async function removeSource() {
deletingDataSource.value = null
}
async function removeSession(ds: DataSource, sess: ImportSession) {
await store.removeImportSession(ds.id, sess.id)
function confirmRemoveSession(ds: DataSource, sess: ImportSession) {
deletingSession.value = { ds, sess }
showDeleteSessionModal.value = true
}
async function removeSessionKeepData() {
if (!deletingSession.value) return
const { ds, sess } = deletingSession.value
await store.removeImportSession(ds.id, sess.id, false)
showDeleteSessionModal.value = false
deletingSession.value = null
}
async function removeSessionDeleteData() {
if (!deletingSession.value) return
const { ds, sess } = deletingSession.value
await store.removeImportSession(ds.id, sess.id, true)
showDeleteSessionModal.value = false
deletingSession.value = null
}
async function syncAllInSource(ds: DataSource) {
@@ -248,10 +267,22 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
<UButton size="xs" variant="ghost" @click="toggleSourceEnabled(ds)">
<UIcon :name="ds.enabled ? 'i-heroicons-pause' : 'i-heroicons-play'" class="h-3.5 w-3.5" />
</UButton>
<UButton size="xs" variant="ghost" @click="syncAllInSource(ds)">
<UButton
size="xs"
variant="ghost"
:loading="pullingIds.has(ds.id)"
:disabled="pullingIds.has(ds.id)"
@click="syncAllInSource(ds)"
>
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5" />
</UButton>
<UButton size="xs" variant="ghost" color="error" @click="confirmDeleteSource(ds)">
<UButton
size="xs"
variant="ghost"
color="error"
:disabled="ds.sessions.some((s) => pullingIds.has(s.id))"
@click="confirmDeleteSource(ds)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
</UButton>
</div>
@@ -269,11 +300,13 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
<span
class="inline-block h-2 w-2 rounded-full"
:class="
sess.lastStatus === 'success'
? 'bg-green-500'
: sess.lastStatus === 'error'
? 'bg-red-500'
: 'bg-gray-400'
pullingIds.has(sess.id)
? 'animate-pulse bg-blue-500'
: sess.lastStatus === 'success'
? 'bg-green-500'
: sess.lastStatus === 'error'
? 'bg-red-500'
: 'bg-gray-400'
"
></span>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ sess.name }}</span>
@@ -282,22 +315,41 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
<UButton
size="xs"
variant="ghost"
:loading="pullingId === sess.id"
:loading="pullingIds.has(sess.id)"
:disabled="pullingIds.has(sess.id)"
@click="syncSession(ds, sess)"
>
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5" />
</UButton>
<UButton size="xs" variant="ghost" color="error" @click="removeSession(ds, sess)">
<UButton
size="xs"
variant="ghost"
color="error"
:disabled="pullingIds.has(sess.id)"
@click="confirmRemoveSession(ds, sess)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
</UButton>
</div>
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>
{{ t('settings.api.dataSources.every') }} {{ ds.intervalMinutes }}
{{ t('settings.api.dataSources.minutes') }}
</span>
<template v-if="sess.lastPullAt">
<template v-if="syncProgress.get(sess.id)">
<span class="text-blue-500">
{{ t('settings.api.dataSources.syncing') }}...
{{ syncProgress.get(sess.id)!.current }}
{{ t('settings.api.dataSources.messages') }}
</span>
</template>
<template v-else-if="pullingIds.has(sess.id) && !syncProgress.get(sess.id)">
<span class="text-blue-500">{{ t('settings.api.dataSources.syncing') }}...</span>
</template>
<template v-else>
<span>
{{ t('settings.api.dataSources.every') }} {{ ds.intervalMinutes }}
{{ t('settings.api.dataSources.minutes') }}
</span>
</template>
<template v-if="!pullingIds.has(sess.id) && sess.lastPullAt">
<span class="text-gray-300 dark:text-gray-600">·</span>
<span class="text-gray-400">
{{ t('settings.api.dataSources.lastSync') }}: {{ formatTime(sess.lastPullAt) }}
@@ -530,7 +582,7 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
@sessions-added="handleSessionsAdded"
/>
<!-- Delete confirmation modal -->
<!-- Delete data source confirmation modal -->
<UModal v-model:open="showDeleteModal" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
<template #content>
<div class="p-4">
@@ -547,5 +599,28 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
</div>
</template>
</UModal>
<!-- Delete session confirmation modal -->
<UModal v-model:open="showDeleteSessionModal" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
<template #content>
<div class="p-4">
<h3 class="mb-3 font-semibold text-gray-900 dark:text-white">
{{ t('settings.api.dataSources.deleteSessionConfirm.title') }}
</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ t('settings.api.dataSources.deleteSessionConfirm.message', { name: deletingSession?.sess.name }) }}
</p>
<div class="flex justify-end gap-2">
<UButton variant="soft" @click="showDeleteSessionModal = false">{{ t('common.cancel') }}</UButton>
<UButton variant="soft" @click="removeSessionKeepData">
{{ t('settings.api.dataSources.deleteSessionConfirm.keepData') }}
</UButton>
<UButton color="error" @click="removeSessionDeleteData">
{{ t('settings.api.dataSources.deleteSessionConfirm.deleteData') }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
+8
View File
@@ -494,6 +494,8 @@
"every": "Every",
"minutes": "min",
"lastSync": "Last sync",
"syncing": "Syncing",
"messages": "messages",
"addBtn": "Add Data Source",
"form": {
"modalTitle": "Add Data Source",
@@ -544,6 +546,12 @@
"deleteConfirm": {
"title": "Delete Data Source",
"message": "Are you sure you want to delete \"{name}\"? All import configurations under this data source will be removed. Already imported local conversations will not be deleted."
},
"deleteSessionConfirm": {
"title": "Delete Subscription",
"message": "Are you sure you want to delete \"{name}\"?",
"keepData": "Remove subscription only",
"deleteData": "Also delete imported chats"
}
},
"usage": {
+8
View File
@@ -493,6 +493,8 @@
"every": "",
"minutes": "分ごと",
"lastSync": "最終同期",
"syncing": "同期中",
"messages": "件",
"addBtn": "データソースを追加",
"form": {
"modalTitle": "データソースを追加",
@@ -544,6 +546,12 @@
"deleteConfirm": {
"title": "データソースを削除",
"message": "データソース「{name}」を削除してもよろしいですか?このデータソース下のすべてのインポート設定が削除されます。既にインポート済みのローカル会話は削除されません。"
},
"deleteSessionConfirm": {
"title": "サブスクリプションを削除",
"message": "「{name}」を削除してもよろしいですか?",
"keepData": "サブスクリプションのみ削除",
"deleteData": "インポートしたチャットも削除"
}
},
"usage": {
+8
View File
@@ -494,6 +494,8 @@
"every": "每",
"minutes": "分钟",
"lastSync": "上次同步",
"syncing": "同步中",
"messages": "条消息",
"addBtn": "添加数据源",
"form": {
"modalTitle": "添加数据源",
@@ -544,6 +546,12 @@
"deleteConfirm": {
"title": "删除数据源",
"message": "确认删除数据源「{name}」?该数据源下的所有导入配置将被移除。已导入的本地对话不会被删除。"
},
"deleteSessionConfirm": {
"title": "删除订阅",
"message": "确认删除订阅「{name}」?",
"keepData": "仅删除订阅",
"deleteData": "同时删除导入的聊天"
}
},
"usage": {
+8
View File
@@ -493,6 +493,8 @@
"every": "每",
"minutes": "分鐘",
"lastSync": "上次同步",
"syncing": "同步中",
"messages": "則訊息",
"addBtn": "新增資料來源",
"form": {
"modalTitle": "新增資料來源",
@@ -544,6 +546,12 @@
"deleteConfirm": {
"title": "刪除資料來源",
"message": "確認刪除資料來源「{name}」?該資料來源下的所有匯入配置將被移除。已匯入的本機對話不會被刪除。"
},
"deleteSessionConfirm": {
"title": "刪除訂閱",
"message": "確認刪除訂閱「{name}」?",
"keepData": "僅刪除訂閱",
"deleteData": "同時刪除匯入的聊天"
}
},
"usage": {
+69 -17
View File
@@ -93,7 +93,7 @@ interface ApiTransport {
sourceId: string,
sessions: Array<{ name: string; remoteSessionId: string }>
): Promise<ImportSession[]>
removeImportSession(sourceId: string, sessionId: string): Promise<boolean>
removeImportSession(sourceId: string, sessionId: string, deleteData?: boolean): Promise<boolean>
triggerPull(sourceId: string, sessionId?: string): Promise<{ success: boolean; error?: string }>
triggerPullAll(sourceId: string): Promise<{ success: boolean; error?: string }>
onPullResult(cb: () => void): () => void
@@ -118,7 +118,7 @@ function createElectronTransport(): ApiTransport {
updateDataSource: (id, updates) => api.updateDataSource(id, updates),
deleteDataSource: (id) => api.deleteDataSource(id),
addImportSessions: (sourceId, sessions) => api.addImportSessions(sourceId, sessions),
removeImportSession: (sourceId, sessionId) => api.removeImportSession(sourceId, sessionId),
removeImportSession: (sourceId, sessionId, deleteData?) => api.removeImportSession(sourceId, sessionId, deleteData),
triggerPull: (sourceId, sessionId?) => api.triggerPull(sourceId, sessionId),
triggerPullAll: (sourceId) => api.triggerPullAll(sourceId),
onPullResult: (cb) => api.onPullResult(cb),
@@ -196,9 +196,10 @@ function createWebTransport(): ApiTransport {
body: JSON.stringify({ sessions }),
}),
removeImportSession: async (sourceId, sessionId) => {
removeImportSession: async (sourceId, sessionId, deleteData?) => {
const qs = deleteData ? '?deleteData=true' : ''
const result = await fetchJson<{ success: boolean }>(
`/_web/automation/data-sources/${sourceId}/sessions/${sessionId}`,
`/_web/automation/data-sources/${sourceId}/sessions/${sessionId}${qs}`,
{ method: 'DELETE' }
)
return result.success
@@ -254,7 +255,8 @@ export const useApiServerStore = defineStore('apiServer', () => {
const loading = ref(false)
const dataSources = ref<DataSource[]>([])
const pullingId = ref<string | null>(null)
const pullingIds = ref(new Set<string>())
const syncProgress = ref<Map<string, { current: number; pages: number }>>(new Map())
const available = computed(() => true)
const isRunning = computed(() => status.value.running)
@@ -385,7 +387,12 @@ export const useApiServerStore = defineStore('apiServer', () => {
const added = await transport.addImportSessions(sourceId, sessions)
await fetchDataSources()
if (added.length > 0) {
pollDataSourceUpdates(sourceId)
for (const s of added) pullingIds.value.add(s.id)
pullingIds.value = new Set(pullingIds.value)
pollDataSourceUpdates(
sourceId,
added.map((s) => s.id)
)
}
return added
} catch (err) {
@@ -394,22 +401,45 @@ export const useApiServerStore = defineStore('apiServer', () => {
}
}
function pollDataSourceUpdates(sourceId: string, maxAttempts = 10, intervalMs = 3000) {
function pollDataSourceUpdates(sourceId: string, sessionIds: string[], maxAttempts = 24, intervalMs = 5000) {
let attempt = 0
const timer = setInterval(async () => {
attempt++
await fetchDataSources()
await Promise.all([fetchDataSources(), fetchSyncProgress()])
const ds = dataSources.value.find((s) => s.id === sourceId)
const allDone = ds?.sessions.every((s) => s.lastStatus !== 'idle')
if (allDone || attempt >= maxAttempts) {
const pending = sessionIds.filter((id) => {
const sess = ds?.sessions.find((s) => s.id === id)
return sess && sess.lastStatus === 'idle'
})
if (pending.length === 0 || attempt >= maxAttempts) {
for (const id of sessionIds) {
pullingIds.value.delete(id)
syncProgress.value.delete(id)
}
pullingIds.value = new Set(pullingIds.value)
syncProgress.value = new Map(syncProgress.value)
clearInterval(timer)
}
}, intervalMs)
}
async function removeImportSession(sourceId: string, sessionId: string) {
async function fetchSyncProgress() {
if (!isWebMode.value) return
try {
const ok = await transport.removeImportSession(sourceId, sessionId)
const list = await fetch('/_web/automation/sync-progress').then((r) => r.json())
const map = new Map<string, { current: number; pages: number }>()
for (const item of list as Array<{ sessionId: string; current: number; pages: number; done: boolean }>) {
if (!item.done) map.set(item.sessionId, { current: item.current, pages: item.pages })
}
syncProgress.value = map
} catch {
/* ignore */
}
}
async function removeImportSession(sourceId: string, sessionId: string, deleteData?: boolean) {
try {
const ok = await transport.removeImportSession(sourceId, sessionId, deleteData)
if (ok) await fetchDataSources()
return ok
} catch (err) {
@@ -421,7 +451,10 @@ export const useApiServerStore = defineStore('apiServer', () => {
// ==================== 同步 ====================
async function triggerPull(sourceId: string, sessionId?: string) {
pullingId.value = sessionId || sourceId
const trackId = sessionId || sourceId
pullingIds.value.add(trackId)
pullingIds.value = new Set(pullingIds.value)
const progressTimer = startProgressPolling()
try {
const result = await transport.triggerPull(sourceId, sessionId)
await fetchDataSources()
@@ -430,12 +463,20 @@ export const useApiServerStore = defineStore('apiServer', () => {
console.error('[ApiServerStore] Failed to trigger pull:', err)
return { success: false, error: String(err) }
} finally {
pullingId.value = null
clearInterval(progressTimer)
pullingIds.value.delete(trackId)
pullingIds.value = new Set(pullingIds.value)
syncProgress.value.delete(trackId)
syncProgress.value = new Map(syncProgress.value)
}
}
async function triggerPullAll(sourceId: string) {
pullingId.value = sourceId
const ds = dataSources.value.find((s) => s.id === sourceId)
const ids = [sourceId, ...(ds?.sessions.map((s) => s.id) ?? [])]
for (const id of ids) pullingIds.value.add(id)
pullingIds.value = new Set(pullingIds.value)
const progressTimer = startProgressPolling()
try {
const result = await transport.triggerPullAll(sourceId)
await fetchDataSources()
@@ -444,10 +485,20 @@ export const useApiServerStore = defineStore('apiServer', () => {
console.error('[ApiServerStore] Failed to trigger pull all:', err)
return { success: false, error: String(err) }
} finally {
pullingId.value = null
clearInterval(progressTimer)
for (const id of ids) {
pullingIds.value.delete(id)
syncProgress.value.delete(id)
}
pullingIds.value = new Set(pullingIds.value)
syncProgress.value = new Map(syncProgress.value)
}
}
function startProgressPolling(): ReturnType<typeof setInterval> {
return setInterval(fetchSyncProgress, 3000)
}
function listenPullResult() {
return transport.onPullResult(() => {
fetchDataSources()
@@ -467,7 +518,8 @@ export const useApiServerStore = defineStore('apiServer', () => {
status,
loading,
dataSources,
pullingId,
pullingIds,
syncProgress,
available,
isRunning,
hasError,