feat(stage-*): refactor settings data page and add app data folder action (#1447)
------ Co-authored-by-agent: Codex
This commit is contained in:
@@ -207,6 +207,7 @@ export default defineConfig({
|
||||
exclude: base => [
|
||||
...base,
|
||||
'**/settings/connection/index.vue',
|
||||
'**/settings/data/index.vue',
|
||||
'**/settings/system/general.vue',
|
||||
'**/settings/modules/mcp.vue',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, defineInvoke } from '@moeru/eventa'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { electronAppOpenUserDataFolder } from '../../../shared/eventa'
|
||||
import { createAppService } from './app'
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
getPath: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
}))
|
||||
|
||||
const shellMock = vi.hoisted(() => ({
|
||||
openPath: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: appMock,
|
||||
shell: shellMock,
|
||||
}))
|
||||
|
||||
vi.mock('std-env', () => ({
|
||||
isLinux: false,
|
||||
isMacOS: false,
|
||||
isWindows: true,
|
||||
}))
|
||||
|
||||
describe('createAppService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens the Electron userData folder and returns its path', async () => {
|
||||
const context = createContext()
|
||||
appMock.getPath.mockReturnValue('/tmp/airi-user-data')
|
||||
shellMock.openPath.mockResolvedValue('')
|
||||
|
||||
createAppService({ context: context as never, window: {} as never })
|
||||
|
||||
const openUserDataFolder = defineInvoke(context, electronAppOpenUserDataFolder)
|
||||
|
||||
await expect(openUserDataFolder()).resolves.toEqual({ path: '/tmp/airi-user-data' })
|
||||
expect(appMock.getPath).toHaveBeenCalledWith('userData')
|
||||
expect(shellMock.openPath).toHaveBeenCalledWith('/tmp/airi-user-data')
|
||||
})
|
||||
|
||||
it('throws when Electron fails to open the userData folder', async () => {
|
||||
const context = createContext()
|
||||
appMock.getPath.mockReturnValue('/tmp/airi-user-data')
|
||||
shellMock.openPath.mockResolvedValue('Failed to open path')
|
||||
|
||||
createAppService({ context: context as never, window: {} as never })
|
||||
|
||||
const openUserDataFolder = defineInvoke(context, electronAppOpenUserDataFolder)
|
||||
|
||||
await expect(openUserDataFolder()).rejects.toThrow('Failed to open path')
|
||||
expect(appMock.getPath).toHaveBeenCalledWith('userData')
|
||||
expect(shellMock.openPath).toHaveBeenCalledWith('/tmp/airi-user-data')
|
||||
})
|
||||
})
|
||||
@@ -2,14 +2,22 @@ import type { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { app } from 'electron'
|
||||
import { app, shell } from 'electron'
|
||||
import { isLinux, isMacOS, isWindows } from 'std-env'
|
||||
|
||||
import { electron, electronAppQuit } from '../../../shared/eventa'
|
||||
import { electron, electronAppOpenUserDataFolder, electronAppQuit } from '../../../shared/eventa'
|
||||
|
||||
export function createAppService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
|
||||
defineInvokeHandler(params.context, electron.app.isMacOS, () => isMacOS)
|
||||
defineInvokeHandler(params.context, electron.app.isWindows, () => isWindows)
|
||||
defineInvokeHandler(params.context, electron.app.isLinux, () => isLinux)
|
||||
defineInvokeHandler(params.context, electronAppOpenUserDataFolder, async () => {
|
||||
const path = app.getPath('userData')
|
||||
const openResult = await shell.openPath(path)
|
||||
if (openResult) {
|
||||
throw new Error(openResult)
|
||||
}
|
||||
return { path }
|
||||
})
|
||||
defineInvokeHandler(params.context, electronAppQuit, () => app.quit())
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataSettingsStatusEmits } from '@proj-airi/stage-pages/pages/settings/data/status'
|
||||
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
import { createDataSettingsStatusHelpers } from '@proj-airi/stage-pages/pages/settings/data/status'
|
||||
import { isElectronWindow } from '@proj-airi/stage-shared'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { electronAppOpenUserDataFolder } from '../../../../../shared/eventa'
|
||||
|
||||
const emit = defineEmits<DataSettingsStatusEmits>()
|
||||
const { t } = useI18n()
|
||||
const { handleActionError } = createDataSettingsStatusHelpers(emit)
|
||||
|
||||
async function triggerOpenDesktopUserDataFolder() {
|
||||
if (typeof window === 'undefined' || !isElectronWindow(window))
|
||||
return
|
||||
|
||||
try {
|
||||
const { context } = createContext(window.electron.ipcRenderer)
|
||||
const openUserDataFolder = defineInvoke(context, electronAppOpenUserDataFolder)
|
||||
|
||||
await openUserDataFolder()
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['border-2 border-sky-200/80 rounded-xl bg-sky-50/80 p-4 shadow-sm', 'dark:border-sky-500/40 dark:bg-sky-500/10']">
|
||||
<div :class="['grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['flex flex-col gap-1 md:max-w-[560px]']">
|
||||
<div :class="['text-lg text-sky-700 font-medium dark:text-sky-200']">
|
||||
{{ t('settings.pages.data.sections.desktop-folder.title') }}
|
||||
</div>
|
||||
<p :class="['text-sm text-sky-700/80 dark:text-sky-200/80']">
|
||||
{{ t('settings.pages.data.sections.desktop-folder.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex flex-col items-start gap-2']">
|
||||
<Button variant="secondary" @click="triggerOpenDesktopUserDataFolder">
|
||||
{{ t('settings.pages.data.sections.desktop-folder.open') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataSettingsStatusEmits } from '@proj-airi/stage-pages/pages/settings/data/status'
|
||||
|
||||
import { createDataSettingsStatusHelpers } from '@proj-airi/stage-pages/pages/settings/data/status'
|
||||
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
|
||||
import { DoubleCheckButton } from '@proj-airi/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const emit = defineEmits<DataSettingsStatusEmits>()
|
||||
const { t } = useI18n()
|
||||
const { resetDesktopApplicationState } = useDataMaintenance()
|
||||
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
|
||||
|
||||
async function resetDesktopState() {
|
||||
try {
|
||||
await resetDesktopApplicationState()
|
||||
emitStatus(t('settings.pages.data.status.desktop_reset'))
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['border-2 border-amber-300/80 rounded-xl bg-amber-50/80 p-4 shadow-sm', 'dark:border-amber-500/60 dark:bg-amber-500/10']">
|
||||
<div :class="['grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['flex flex-col gap-1 md:max-w-[560px]']">
|
||||
<div :class="['text-lg text-amber-700 font-medium dark:text-amber-200']">
|
||||
{{ t('settings.pages.data.sections.desktop.title') }}
|
||||
</div>
|
||||
<p :class="['text-sm text-amber-700/80 dark:text-amber-200/80']">
|
||||
{{ t('settings.pages.data.sections.desktop.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex flex-col items-start gap-2']">
|
||||
<DoubleCheckButton variant="caution" @confirm="resetDesktopState">
|
||||
{{ t('settings.pages.data.sections.desktop.reset') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import ChatsSection from '@proj-airi/stage-pages/pages/settings/data/components/chats-section.vue'
|
||||
import DangerSection from '@proj-airi/stage-pages/pages/settings/data/components/danger-section.vue'
|
||||
import ModelsModulesSection from '@proj-airi/stage-pages/pages/settings/data/components/models-modules-section.vue'
|
||||
import StatusBanner from '@proj-airi/stage-pages/pages/settings/data/components/status-banner.vue'
|
||||
|
||||
import { createDataSettingsStatusState } from '@proj-airi/stage-pages/pages/settings/data/status'
|
||||
|
||||
import DesktopFolderSection from './components/desktop-folder-section.vue'
|
||||
import DesktopResetSection from './components/desktop-reset-section.vue'
|
||||
|
||||
const { statusMessage, statusTone, handleStatus } = createDataSettingsStatusState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col gap-4 pb-4']">
|
||||
<StatusBanner v-if="statusMessage" :message="statusMessage" :tone="statusTone" />
|
||||
<ChatsSection @status="handleStatus" />
|
||||
<ModelsModulesSection @status="handleStatus" />
|
||||
<DesktopFolderSection @status="handleStatus" />
|
||||
<DesktopResetSection @status="handleStatus" />
|
||||
<DangerSection @status="handleStatus" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
titleKey: settings.pages.data.title
|
||||
subtitleKey: settings.title
|
||||
descriptionKey: settings.pages.data.description
|
||||
icon: i-solar:database-bold-duotone
|
||||
settingsEntry: true
|
||||
order: 7
|
||||
stageTransition:
|
||||
name: slide
|
||||
pageSpecificAvailable: true
|
||||
</route>
|
||||
@@ -226,6 +226,7 @@ export interface ElectronWindowLifecycleState {
|
||||
export const electronWindowLifecycleChanged = defineEventa<ElectronWindowLifecycleState>('eventa:event:electron:window:lifecycle-changed')
|
||||
export const electronGetWindowLifecycleState = defineInvokeEventa<ElectronWindowLifecycleState>('eventa:invoke:electron:window:get-lifecycle-state')
|
||||
export const electronWindowSetAlwaysOnTop = defineInvokeEventa<void, boolean>('eventa:invoke:electron:window:set-always-on-top')
|
||||
export const electronAppOpenUserDataFolder = defineInvokeEventa<{ path: string }>('eventa:invoke:electron:app:open-user-data-folder')
|
||||
export const electronAppQuit = defineInvokeEventa<void>('eventa:invoke:electron:app:quit')
|
||||
|
||||
export type StageThreeRuntimeTraceEnvelope
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"useDefineForClassFields": true,
|
||||
"baseUrl": "./src/renderer",
|
||||
"paths": {
|
||||
"@proj-airi/stage-pages/*.vue": [
|
||||
"../../packages/stage-pages/src/*.vue"
|
||||
],
|
||||
"@proj-airi/stage-pages/*": [
|
||||
"../../packages/stage-pages/src/*.ts"
|
||||
],
|
||||
"@proj-airi/stage-ui/*": [
|
||||
"../../packages/stage-ui/src/*"
|
||||
],
|
||||
|
||||
@@ -224,6 +224,10 @@ pages:
|
||||
title: Delete all data
|
||||
description: Wipe every local setting, provider config, and model.
|
||||
delete: Delete all data
|
||||
desktop-folder:
|
||||
title: Open app data folder
|
||||
description: Open AIRI's data folder in your file manager.
|
||||
open: Open folder
|
||||
desktop:
|
||||
title: Reset desktop settings & states
|
||||
description: Clear AIRI desktop settings and runtime state.
|
||||
|
||||
@@ -217,6 +217,10 @@ pages:
|
||||
title: 删除所有数据
|
||||
description: 清除每个本地设置、提供商配置和模型。
|
||||
delete: 删除所有数据
|
||||
desktop-folder:
|
||||
title: 打开应用数据文件夹
|
||||
description: 在文件管理器中打开 AIRI 的数据文件夹。
|
||||
open: 打开文件夹
|
||||
desktop:
|
||||
title: 重置桌面设置和状态
|
||||
description: 清除 AIRI 桌面设置和运行状态。
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"directory": "packages/stage-pages"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*"
|
||||
"./*.vue": "./src/*.vue",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataSettingsStatusEmits } from '../status'
|
||||
|
||||
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
|
||||
import { Button, DoubleCheckButton } from '@proj-airi/ui'
|
||||
import { shallowRef, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { createDataSettingsStatusHelpers } from '../status'
|
||||
|
||||
const emit = defineEmits<DataSettingsStatusEmits>()
|
||||
const { t } = useI18n()
|
||||
const importFileInput = useTemplateRef<HTMLInputElement>('importFileInput')
|
||||
const importError = shallowRef('')
|
||||
const {
|
||||
deleteAllChatSessions,
|
||||
exportChatSessions,
|
||||
importChatSessions,
|
||||
} = useDataMaintenance()
|
||||
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
|
||||
|
||||
function triggerImportPicker() {
|
||||
importFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function triggerExport() {
|
||||
try {
|
||||
const blob = await exportChatSessions()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `airi-chat-sessions-${new Date().toISOString()}.json`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
emitStatus(t('settings.pages.data.status.exported'))
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChats() {
|
||||
try {
|
||||
deleteAllChatSessions()
|
||||
emitStatus(t('settings.pages.data.status.chats_deleted'))
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
|
||||
try {
|
||||
const raw = await file.text()
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
await importChatSessions(parsed)
|
||||
importError.value = ''
|
||||
emitStatus(t('settings.pages.data.status.imported'))
|
||||
}
|
||||
catch (error) {
|
||||
importError.value = t('settings.pages.data.status.import_error')
|
||||
handleActionError(error)
|
||||
}
|
||||
finally {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['border-2 border-neutral-200/50 rounded-xl bg-white/70 p-4 shadow-sm', 'dark:border-neutral-800/60 dark:bg-neutral-900/60']">
|
||||
<div :class="['grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['flex flex-col gap-1 md:max-w-[560px]']">
|
||||
<div :class="['text-lg font-medium']">
|
||||
{{ t('settings.pages.data.sections.chats.title') }}
|
||||
</div>
|
||||
<p :class="['text-sm text-neutral-600 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.data.sections.chats.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex flex-col items-start gap-2 sm:items-end']">
|
||||
<div :class="['flex flex-wrap gap-2']">
|
||||
<Button variant="secondary" @click="triggerExport">
|
||||
{{ t('settings.pages.data.sections.chats.export') }}
|
||||
</Button>
|
||||
<Button variant="primary" @click="triggerImportPicker">
|
||||
{{ t('settings.pages.data.sections.chats.import') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DoubleCheckButton variant="danger" @confirm="deleteChats">
|
||||
{{ t('settings.pages.data.sections.chats.delete') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="importFileInput" type="file" accept="application/json" :class="['hidden']" @change="handleImport">
|
||||
<p v-if="importError" :class="['text-sm text-red-500']">
|
||||
{{ importError }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataSettingsStatusEmits } from '../status'
|
||||
|
||||
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
|
||||
import { DoubleCheckButton } from '@proj-airi/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { createDataSettingsStatusHelpers } from '../status'
|
||||
|
||||
const emit = defineEmits<DataSettingsStatusEmits>()
|
||||
const { t } = useI18n()
|
||||
const { deleteAllData, resetProvidersSettings } = useDataMaintenance()
|
||||
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
|
||||
|
||||
async function resetProviders() {
|
||||
try {
|
||||
await resetProvidersSettings()
|
||||
emitStatus(t('settings.pages.data.status.providers_reset'))
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAll() {
|
||||
try {
|
||||
await deleteAllData()
|
||||
emitStatus(t('settings.pages.data.status.all_deleted'))
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['border-2 border-red-300/80 rounded-xl bg-red-50/80 p-4 shadow-sm', 'dark:border-red-500/60 dark:bg-red-500/10']">
|
||||
<div :class="['flex flex-col gap-3']">
|
||||
<div>
|
||||
<div :class="['text-lg text-red-600 font-semibold dark:text-red-300']">
|
||||
{{ t('settings.pages.data.sections.danger.title') }}
|
||||
</div>
|
||||
<p :class="['text-sm text-red-600/80 dark:text-red-200/80']">
|
||||
{{ t('settings.pages.data.sections.danger.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :class="['flex flex-col gap-3']">
|
||||
<div :class="['grid gap-3 md:grid-cols-2']">
|
||||
<div :class="['grid grid-cols-1 items-start gap-2 p-3 md:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['flex flex-col gap-1 md:max-w-[560px]']">
|
||||
<div :class="['text-sm text-red-700 font-medium dark:text-red-200']">
|
||||
{{ t('settings.pages.data.sections.providers.title') }}
|
||||
</div>
|
||||
<p :class="['text-xs text-red-700/80 dark:text-red-200/80']">
|
||||
{{ t('settings.pages.data.sections.providers.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex flex-col items-start gap-2']">
|
||||
<DoubleCheckButton variant="danger" @confirm="resetProviders">
|
||||
{{ t('settings.pages.data.sections.providers.reset') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['grid grid-cols-1 items-start gap-2 p-3 md:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['flex flex-col gap-1 md:max-w-[560px]']">
|
||||
<div :class="['text-sm text-red-700 font-medium dark:text-red-200']">
|
||||
{{ t('settings.pages.data.sections.all.title') }}
|
||||
</div>
|
||||
<p :class="['text-xs text-red-700/80 dark:text-red-200/80']">
|
||||
{{ t('settings.pages.data.sections.all.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex flex-col items-start gap-2']">
|
||||
<DoubleCheckButton variant="danger" @confirm="deleteAll">
|
||||
{{ t('settings.pages.data.sections.all.delete') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataSettingsStatusEmits } from '../status'
|
||||
|
||||
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
|
||||
import { DoubleCheckButton } from '@proj-airi/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { createDataSettingsStatusHelpers } from '../status'
|
||||
|
||||
const emit = defineEmits<DataSettingsStatusEmits>()
|
||||
const { t } = useI18n()
|
||||
const { deleteAllModels, resetModulesSettings } = useDataMaintenance()
|
||||
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
|
||||
|
||||
async function deleteModels() {
|
||||
try {
|
||||
await deleteAllModels()
|
||||
emitStatus(t('settings.pages.data.status.models_deleted'))
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function resetModules() {
|
||||
try {
|
||||
resetModulesSettings()
|
||||
emitStatus(t('settings.pages.data.status.modules_reset'))
|
||||
}
|
||||
catch (error) {
|
||||
handleActionError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['border-2 border-neutral-200/50 rounded-xl bg-white/70 p-4 shadow-sm', 'dark:border-neutral-800/60 dark:bg-neutral-900/60']">
|
||||
<div :class="['flex flex-col gap-3']">
|
||||
<div :class="['grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['flex flex-col gap-1 md:max-w-[560px]']">
|
||||
<div :class="['text-lg font-medium']">
|
||||
{{ t('settings.pages.data.sections.models.title') }}
|
||||
</div>
|
||||
<p :class="['text-sm text-neutral-600 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.data.sections.models.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex flex-col items-start gap-2']">
|
||||
<DoubleCheckButton variant="danger" @confirm="deleteModels">
|
||||
{{ t('settings.pages.data.sections.models.delete') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['flex flex-col gap-1 md:max-w-[560px]']">
|
||||
<div :class="['text-lg font-medium']">
|
||||
{{ t('settings.pages.data.sections.modules.title') }}
|
||||
</div>
|
||||
<p :class="['text-sm text-neutral-600 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.data.sections.modules.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex flex-col items-start gap-2']">
|
||||
<DoubleCheckButton variant="caution" @confirm="resetModules">
|
||||
{{ t('settings.pages.data.sections.modules.reset') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataSettingsStatusTone } from '../status'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
tone: DataSettingsStatusTone
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'rounded-xl border px-4 py-3 text-sm shadow-sm',
|
||||
tone === 'success' && 'border-emerald-300/80 bg-emerald-50/80 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200',
|
||||
tone === 'error' && 'border-red-300/80 bg-red-50/80 text-red-700 dark:border-red-500/50 dark:bg-red-500/10 dark:text-red-200',
|
||||
tone === 'neutral' && 'border-neutral-200/70 bg-white/70 text-neutral-700 dark:border-neutral-700/70 dark:bg-neutral-900/60 dark:text-neutral-200',
|
||||
]"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,281 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
|
||||
import { Button, DoubleCheckButton } from '@proj-airi/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ChatsSection from './components/chats-section.vue'
|
||||
import DangerSection from './components/danger-section.vue'
|
||||
import ModelsModulesSection from './components/models-modules-section.vue'
|
||||
import StatusBanner from './components/status-banner.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
deleteAllModels,
|
||||
resetProvidersSettings,
|
||||
resetModulesSettings,
|
||||
deleteAllChatSessions,
|
||||
exportChatSessions,
|
||||
importChatSessions,
|
||||
deleteAllData,
|
||||
resetDesktopApplicationState,
|
||||
} = useDataMaintenance()
|
||||
import { createDataSettingsStatusState } from './status'
|
||||
|
||||
const statusMessage = ref('')
|
||||
const statusTone = ref<'neutral' | 'success' | 'error'>('neutral')
|
||||
const importError = ref('')
|
||||
const importFileInput = ref<HTMLInputElement>()
|
||||
const isDesktop = computed(() => isStageTamagotchi())
|
||||
|
||||
function setStatus(message: string, tone: 'neutral' | 'success' | 'error' = 'success') {
|
||||
statusMessage.value = message
|
||||
statusTone.value = tone
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<void> | void, successKey: string) {
|
||||
try {
|
||||
await action()
|
||||
setStatus(t(successKey), 'success')
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
setStatus(error instanceof Error ? error.message : String(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerExport() {
|
||||
try {
|
||||
const blob = await exportChatSessions()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `airi-chat-sessions-${new Date().toISOString()}.json`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setStatus(t('settings.pages.data.status.exported'))
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
setStatus(error instanceof Error ? error.message : String(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImportPicker() {
|
||||
importError.value = ''
|
||||
importFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImport(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
|
||||
try {
|
||||
const raw = await file.text()
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
await importChatSessions(parsed)
|
||||
setStatus(t('settings.pages.data.status.imported'))
|
||||
importError.value = ''
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
importError.value = t('settings.pages.data.status.import_error')
|
||||
setStatus(error instanceof Error ? error.message : String(error), 'error')
|
||||
}
|
||||
finally {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
const { statusMessage, statusTone, handleStatus } = createDataSettingsStatusState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
<div class="border-2 border-neutral-200/50 rounded-xl bg-white/70 p-4 shadow-sm dark:border-neutral-800/60 dark:bg-neutral-900/60">
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="flex flex-col gap-1 md:max-w-[560px]">
|
||||
<div class="text-lg font-medium">
|
||||
{{ t('settings.pages.data.sections.chats.title') }}
|
||||
</div>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{{ t('settings.pages.data.sections.chats.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 sm:items-end">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" @click="triggerExport">
|
||||
{{ t('settings.pages.data.sections.chats.export') }}
|
||||
</Button>
|
||||
<Button variant="primary" @click="triggerImportPicker">
|
||||
{{ t('settings.pages.data.sections.chats.import') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DoubleCheckButton
|
||||
variant="danger"
|
||||
@confirm="runAction(deleteAllChatSessions, 'settings.pages.data.status.chats_deleted')"
|
||||
>
|
||||
{{ t('settings.pages.data.sections.chats.delete') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="importFileInput" type="file" accept="application/json" class="hidden" @change="handleImport">
|
||||
<p v-if="importError" class="text-sm text-red-500">
|
||||
{{ importError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-neutral-200/50 rounded-xl bg-white/70 p-4 shadow-sm dark:border-neutral-800/60 dark:bg-neutral-900/60">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="flex flex-col gap-1 md:max-w-[560px]">
|
||||
<div class="text-lg font-medium">
|
||||
{{ t('settings.pages.data.sections.models.title') }}
|
||||
</div>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{{ t('settings.pages.data.sections.models.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<DoubleCheckButton
|
||||
variant="danger"
|
||||
@confirm="runAction(deleteAllModels, 'settings.pages.data.status.models_deleted')"
|
||||
>
|
||||
{{ t('settings.pages.data.sections.models.delete') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="flex flex-col gap-1 md:max-w-[560px]">
|
||||
<div class="text-lg font-medium">
|
||||
{{ t('settings.pages.data.sections.modules.title') }}
|
||||
</div>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{{ t('settings.pages.data.sections.modules.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<DoubleCheckButton
|
||||
variant="caution"
|
||||
@confirm="runAction(resetModulesSettings, 'settings.pages.data.status.modules_reset')"
|
||||
>
|
||||
{{ t('settings.pages.data.sections.modules.reset') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDesktop"
|
||||
class="border-2 border-amber-300/80 rounded-xl bg-amber-50/80 p-4 shadow-sm dark:border-amber-500/60 dark:bg-amber-500/10"
|
||||
>
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="flex flex-col gap-1 md:max-w-[560px]">
|
||||
<div class="text-lg text-amber-700 font-medium dark:text-amber-200">
|
||||
{{ t('settings.pages.data.sections.desktop.title') }}
|
||||
</div>
|
||||
<p class="text-sm text-amber-700/80 dark:text-amber-200/80">
|
||||
{{ t('settings.pages.data.sections.desktop.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<DoubleCheckButton
|
||||
variant="caution"
|
||||
@confirm="runAction(resetDesktopApplicationState, 'settings.pages.data.status.desktop_reset')"
|
||||
>
|
||||
{{ t('settings.pages.data.sections.desktop.reset') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-red-300/80 rounded-xl bg-red-50/80 p-4 shadow-sm dark:border-red-500/60 dark:bg-red-500/10">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-lg text-red-600 font-semibold dark:text-red-300">
|
||||
{{ t('settings.pages.data.sections.danger.title') }}
|
||||
</div>
|
||||
<p class="text-sm text-red-600/80 dark:text-red-200/80">
|
||||
{{ t('settings.pages.data.sections.danger.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="grid grid-cols-1 items-start gap-2 p-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="flex flex-col gap-1 md:max-w-[560px]">
|
||||
<div class="text-sm text-red-700 font-medium dark:text-red-200">
|
||||
{{ t('settings.pages.data.sections.providers.title') }}
|
||||
</div>
|
||||
<p class="text-xs text-red-700/80 dark:text-red-200/80">
|
||||
{{ t('settings.pages.data.sections.providers.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<DoubleCheckButton
|
||||
variant="danger"
|
||||
@confirm="runAction(resetProvidersSettings, 'settings.pages.data.status.providers_reset')"
|
||||
>
|
||||
{{ t('settings.pages.data.sections.providers.reset') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 items-start gap-2 p-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="flex flex-col gap-1 md:max-w-[560px]">
|
||||
<div class="text-sm text-red-700 font-medium dark:text-red-200">
|
||||
{{ t('settings.pages.data.sections.all.title') }}
|
||||
</div>
|
||||
<p class="text-xs text-red-700/80 dark:text-red-200/80">
|
||||
{{ t('settings.pages.data.sections.all.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<DoubleCheckButton
|
||||
variant="danger"
|
||||
@confirm="runAction(deleteAllData, 'settings.pages.data.status.all_deleted')"
|
||||
>
|
||||
{{ t('settings.pages.data.sections.all.delete') }}
|
||||
<template #confirm>
|
||||
{{ t('settings.pages.data.confirmations.yes') }}
|
||||
</template>
|
||||
<template #cancel>
|
||||
{{ t('settings.pages.card.cancel') }}
|
||||
</template>
|
||||
</DoubleCheckButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['flex flex-col gap-4 pb-4']">
|
||||
<StatusBanner v-if="statusMessage" :message="statusMessage" :tone="statusTone" />
|
||||
<ChatsSection @status="handleStatus" />
|
||||
<ModelsModulesSection @status="handleStatus" />
|
||||
<DangerSection @status="handleStatus" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
export type DataSettingsStatusTone = 'neutral' | 'success' | 'error'
|
||||
|
||||
export interface DataSettingsStatusPayload {
|
||||
message: string
|
||||
tone: DataSettingsStatusTone
|
||||
}
|
||||
|
||||
export interface DataSettingsStatusEmits {
|
||||
status: [payload: DataSettingsStatusPayload]
|
||||
}
|
||||
|
||||
export type DataSettingsStatusEmit = (event: 'status', payload: DataSettingsStatusPayload) => void
|
||||
|
||||
export function createDataSettingsStatusHelpers(emit: DataSettingsStatusEmit) {
|
||||
function emitStatus(message: string, tone: DataSettingsStatusTone = 'success') {
|
||||
emit('status', { message, tone })
|
||||
}
|
||||
|
||||
function handleActionError(error: unknown) {
|
||||
console.error(error)
|
||||
emitStatus(errorMessageFrom(error) ?? 'Unknown error', 'error')
|
||||
}
|
||||
|
||||
return {
|
||||
emitStatus,
|
||||
handleActionError,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDataSettingsStatusState() {
|
||||
const statusMessage = shallowRef('')
|
||||
const statusTone = shallowRef<DataSettingsStatusTone>('neutral')
|
||||
|
||||
function handleStatus(payload: DataSettingsStatusPayload) {
|
||||
statusMessage.value = payload.message
|
||||
statusTone.value = payload.tone
|
||||
}
|
||||
|
||||
return {
|
||||
statusMessage,
|
||||
statusTone,
|
||||
handleStatus,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user