feat(stage-*): refactor settings data page and add app data folder action (#1447)

------

Co-authored-by-agent: Codex
This commit is contained in:
Garfield Lee
2026-03-22 04:39:46 +08:00
committed by GitHub
parent e952fe779e
commit 4a29598037
17 changed files with 598 additions and 275 deletions
@@ -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())
}
@@ -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>
@@ -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
+6
View File
@@ -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 桌面设置和运行状态。
+2 -1
View File
@@ -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,
}
}