Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/peer-saved-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@inkcre/core': patch
'@inkcre/client-web': patch
'@inkcre/client-webext': patch
---

支持在 Web 与浏览器扩展保存、切换命名连接,并保留连接身份和上次选择;Web 备份包含完整连接列表。
19 changes: 17 additions & 2 deletions apps/client-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ const connectionNoticeKey = computed(() => {
return 'startup.peerConnecting'
})
const reload = () => window.location.reload()
const connectionGeneration = ref(0)
watch(
() => [
configStore.metaConfig.INKCRE_PGREST_URL,
configStore.metaConfig.INKCRE_JWT_SECRET,
configStore.metaConfig.INKCRE_PEER_ID,
],
() => {
connectionGeneration.value += 1
}
)

onMounted(async () => {
const loadPeerConfig = shouldLoadPeerConfigAtBootstrap(window.location.pathname)
Expand Down Expand Up @@ -204,9 +215,13 @@ const connectionNoticeVisible = computed(
<a href="/settings">{{ t('common.settings') }}</a>
</div>
</main>
<AppSidePanel v-model:expanded="sidebarExpanded" :ready="startupState === 'ready'" />
<AppSidePanel
:key="connectionGeneration"
v-model:expanded="sidebarExpanded"
:ready="startupState === 'ready'"
/>
</div>
<RecallSearch v-if="startupState === 'ready'" />
<RecallSearch :key="connectionGeneration" v-if="startupState === 'ready'" />
<ToastHost />
</div>
</template>
Expand Down
2 changes: 2 additions & 0 deletions apps/client-web/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
localStorageAdapter,
registerCoreResolvers,
TextResolver,
ResolverCache,
AudioResolver,
EpubResolver,
FileResolver,
Expand Down Expand Up @@ -340,6 +341,7 @@ export async function stopWebPeerRuntime(): Promise<void> {
if (webPeerRuntime !== null) return
extensionHostStartup = null
extensionHostRestoreState.value = 'idle'
ResolverCache.clear()
}

/** Drain work against the old connection before configuration changes its database authority. */
Expand Down
6 changes: 5 additions & 1 deletion apps/client-web/src/locales/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@
"peerRuntime": "Local background",
"telemetry": "Telemetry",
"telemetryRelayDefault": "Leave empty to use deployment telemetry endpoints.",
"backgroundDisabledEffect": "Saving stops background tasks and telemetry; local extensions remain available."
"backgroundDisabledEffect": "Saving stops background tasks and telemetry; local extensions remain available.",
"currentConnection": "Current connection",
"addConnection": "Add connection",
"connectionName": "Connection name",
"saveAndUseConnection": "Save and use connection"
},
"source": {
"lead": "Manage the places your InKCre collects from; each source is an ongoing channel of new content.",
Expand Down
6 changes: 5 additions & 1 deletion apps/client-web/src/locales/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@
"peerRuntime": "本机后台",
"telemetry": "遥测",
"telemetryRelayDefault": "留空使用部署的遥测出口。",
"backgroundDisabledEffect": "保存后停止后台任务和遥测;本地扩展仍可使用。"
"backgroundDisabledEffect": "保存后停止后台任务和遥测;本地扩展仍可使用。",
"currentConnection": "当前连接",
"addConnection": "添加连接",
"connectionName": "连接名称",
"saveAndUseConnection": "保存并使用连接"
},
"source": {
"lead": "管理采集数据来源;每个数据源对应一个持续进入 InKCre 的内容通道。",
Expand Down
124 changes: 113 additions & 11 deletions apps/client-web/src/views/settings/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import {
InkSwitch,
} from '@inkcre/ui-web'
import { toast } from '@/components/toast/toast'
import { configStore, MetaConfigSchema, type MetaConfig } from '@inkcre/core'
import {
configStore,
MetaConfigSchema,
ConnectionSettingsSchema,
readConnectionSettings,
type MetaConfig,
type ConnectionSettings,
} from '@inkcre/core'
import { z } from 'zod'
import { setLocale, SUPPORT_LOCALES, LOCALE_NAMES, type SupportLocale } from '@/locales'
import i18n from '@/locales'
Expand All @@ -28,6 +35,42 @@ const { t } = useI18n()
const telemetryLabelId = useId()

const metaFormConfig = reactive<MetaConfig>({ ...configStore.metaConfig })
const editingId = ref(configStore.connectionSettings.activeConnectionId ?? crypto.randomUUID())
const connectionName = ref(
configStore.connectionSettings.connections.find((item) => item.id === editingId.value)?.name ?? ''
)
const addingConnection = ref(false)
const connectionOptions = computed(() =>
configStore.connectionSettings.connections.map((item) => ({ value: item.id, label: item.name }))
)

function newConnection() {
editingId.value = crypto.randomUUID()
connectionName.value = ''
addingConnection.value = true
Object.assign(metaFormConfig, MetaConfigSchema.parse({}))
feedback.value = null
}

async function switchConnection(id: string | number) {
const saved = configStore.connectionSettings.connections.find((item) => item.id === id)
if (!saved || formBusy.value) return
editingId.value = saved.id
connectionName.value = saved.name
Object.assign(metaFormConfig, saved.config)
addingConnection.value = false
const savedSuccessfully = await onSave()
if (!savedSuccessfully) restoreCurrentConnection()
}

function restoreCurrentConnection() {
Object.assign(metaFormConfig, configStore.metaConfig)
editingId.value = configStore.connectionSettings.activeConnectionId ?? crypto.randomUUID()
connectionName.value =
configStore.connectionSettings.connections.find((item) => item.id === editingId.value)?.name ??
''
addingConnection.value = false
}
const pendingAction = ref<'save' | 'import' | 'reset' | null>(null)
const feedback = ref<{
action: 'save' | 'import' | 'reset'
Expand Down Expand Up @@ -70,17 +113,30 @@ const leaseRenewIntervalError = computed(() => {
? t('settings.peerLeaseRenewIntervalInvalid')
: ''
})
const SettingsExportSchema = z.object({
version: z.literal(2),
metaConfig: MetaConfigSchema,
locale: z.enum(SUPPORT_LOCALES),
})
const SettingsExportSchema = z.union([
z.object({
version: z.literal(2),
metaConfig: MetaConfigSchema,
locale: z.enum(SUPPORT_LOCALES),
}),
z.object({
version: z.literal(3),
metaConfig: MetaConfigSchema,
locale: z.enum(SUPPORT_LOCALES),
connections: ConnectionSettingsSchema,
}),
])

// Synchronize metaFormConfig with configStore.metaConfig
watch(
() => configStore.metaConfig,
(newMetaConfig) => {
Object.assign(metaFormConfig, newMetaConfig)
editingId.value = configStore.connectionSettings.activeConnectionId ?? crypto.randomUUID()
connectionName.value =
configStore.connectionSettings.connections.find((item) => item.id === editingId.value)
?.name ?? ''
addingConnection.value = false
},
{ deep: true }
)
Expand Down Expand Up @@ -108,18 +164,24 @@ const onSave = async () => {
try {
const validatedMeta = MetaConfigSchema.parse(metaFormConfig)
await prepareWebPeerConfigurationChange(validatedMeta)
const runtime = await configStore.connectAndSave(validatedMeta, WEB_PEER_IDENTITY)
const name = connectionName.value.trim() || new URL(validatedMeta.INKCRE_PGREST_URL).host
const runtime = await configStore.connectAndSave(validatedMeta, WEB_PEER_IDENTITY, {
id: editingId.value,
name,
})
await adoptWebPeerRuntime(runtime)
void refreshCurrentWebPeer()
Object.assign(metaFormConfig, configStore.metaConfig)
feedback.value = { action: 'save', error: false, message: t('settings.saveSuccess') }
toast.success(t('settings.saveSuccess'))
return true
} catch (error) {
if (webPeerConnectionState.value === 'idle' && hasConnectedConfig.value)
webPeerConnectionState.value = 'failed'
console.error('Failed to save config:', error)
feedback.value = { action: 'save', error: true, message: t('common.saveFailed') }
toast.error(t('common.saveFailed'))
return false
} finally {
pendingAction.value = null
}
Expand All @@ -142,7 +204,8 @@ const onReset = async () => {

const onExport = () => {
const portableConfig = SettingsExportSchema.parse({
version: 2,
version: 3,
connections: configStore.connectionSettings,
metaConfig: configStore.metaConfig,
locale: currentLocale.value,
})
Expand Down Expand Up @@ -173,8 +236,21 @@ const onFileSelected = async (event: Event) => {
feedback.value = null
try {
const imported = SettingsExportSchema.parse(JSON.parse(await file.text()))
await prepareWebPeerConfigurationChange(imported.metaConfig)
const runtime = await configStore.connectAndSave(imported.metaConfig, WEB_PEER_IDENTITY)
const connections: ConnectionSettings =
imported.version === 3
? imported.connections
: readConnectionSettings(imported.metaConfig, imported.metaConfig)
const selected = connections.connections.find(
(item) => item.id === connections.activeConnectionId
)
if (!selected) throw new Error('The backup has no selected connection')
await prepareWebPeerConfigurationChange(selected.config)
const runtime = await configStore.connectAndSave(
selected.config,
WEB_PEER_IDENTITY,
selected,
connections
)
await adoptWebPeerRuntime(runtime)
void refreshCurrentWebPeer()
await setLocale(imported.locale)
Expand Down Expand Up @@ -203,6 +279,32 @@ const onFileSelected = async (event: Event) => {
>
<fieldset class="settings-view__group">
<legend>{{ t('settings.deploymentConnection') }}</legend>
<InkDropdown
v-if="connectionOptions.length"
:model-value="configStore.connectionSettings.activeConnectionId"
:label="t('settings.currentConnection')"
:options="connectionOptions"
:disabled="formBusy"
@update:model-value="switchConnection"
/>
<div class="settings-view__actions">
<InkButton
:text="t('settings.addConnection')"
:disabled="formBusy"
@click="newConnection"
/>
<InkButton
v-if="addingConnection && configStore.connectionSettings.activeConnectionId"
:text="t('common.cancel')"
:disabled="formBusy"
@click="restoreCurrentConnection"
/>
</div>
<InkInput
v-model="connectionName"
:label="t('settings.connectionName')"
:disabled="formBusy"
/>
<InkInput
v-model="metaFormConfig.INKCRE_PGREST_URL"
:label="t('settings.pgrestUrl')"
Expand Down Expand Up @@ -267,7 +369,7 @@ const onFileSelected = async (event: Event) => {

<div class="settings-view__actions">
<InkButton
:text="t('settings.saveConfig')"
:text="t('settings.saveAndUseConnection')"
theme="primary"
:disabled="formBusy"
:is-loading="pendingAction === 'save'"
Expand Down
94 changes: 83 additions & 11 deletions apps/client-webext/entrypoints/options/Options.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,58 @@
<script setup lang="ts">
import logo from '@/assets/inkcre.svg'
import { type LLMProviderConfig, type ProviderType } from '@inkcre/core'
import { defaultModel, explainInstruction, llmProviders, stopwords } from '@/logic/storage'
import {
defaultModel,
explainInstruction,
inkcreApi,
llmProviders,
stopwords,
} from '@/logic/storage'
apiConnections,
apiConnectionsReady,
apiConnectionIssue,
saveApiConnection,
selectApiConnection,
} from '@/logic/api-connections'
import 'uno.css'

const connectionId = ref<string>(crypto.randomUUID())
const connectionName = ref('')
const connectionUrl = ref('')
const connectionBusy = ref(true)
const connectionFeedback = ref('')

function showActiveConnection() {
const current = apiConnections.value.connections.find(
(item) => item.id === apiConnections.value.activeId
)
connectionId.value = current?.id ?? crypto.randomUUID()
connectionName.value = current?.name ?? ''
connectionUrl.value = current?.url ?? ''
}
onMounted(async () => {
await apiConnectionsReady
showActiveConnection()
connectionBusy.value = false
})
watch(apiConnections, showActiveConnection)
function addConnection() {
connectionId.value = crypto.randomUUID()
connectionName.value = ''
connectionUrl.value = ''
connectionFeedback.value = ''
}
async function applyConnection(id?: string) {
if (connectionBusy.value) return
connectionBusy.value = true
connectionFeedback.value = ''
try {
if (id) await selectApiConnection(id)
else await saveApiConnection(connectionId.value, connectionName.value, connectionUrl.value)
showActiveConnection()
connectionFeedback.value = '已切换连接。'
} catch {
connectionFeedback.value = '无法保存连接,请检查名称和 HTTP(S) 地址。'
} finally {
connectionBusy.value = false
}
}

// Computed property to handle stopwords array/string conversion
const stopwordsText = computed({
get: () => (Array.isArray(stopwords.value) ? stopwords.value.join(', ') : ''),
Expand Down Expand Up @@ -131,16 +174,45 @@ onMounted(() => {
<div class="mt-6 p-4 bg-surface-2 rounded-none border border-border">
<h3 class="text-lg font-semibold mb-2">Runtime configuration</h3>
<div class="text-left space-y-4">
<div class="flex items-center space-x-2">
<label for="inkcre-api" class="font-medium">INKCRE_API:</label>
<fieldset :disabled="connectionBusy" class="space-y-2">
<legend class="font-semibold">连接</legend>
<label for="current-connection">当前连接</label>
<select
id="current-connection"
:value="apiConnections.activeId ?? ''"
@change="applyConnection(($event.target as HTMLSelectElement).value)"
class="w-full px-2 py-1 border border-border bg-surface"
>
<option v-if="!apiConnections.connections.length" value="" disabled>
尚未保存连接
</option>
<option v-for="item in apiConnections.connections" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
<button type="button" @click="addConnection" class="px-3 py-1 border border-border">
添加连接
</button>
<label for="connection-name" class="block">连接名称</label>
<input
id="connection-name"
v-model="connectionName"
class="w-full px-2 py-1 border border-border bg-surface"
/>
<label for="inkcre-api" class="block">API 地址</label>
<input
id="inkcre-api"
v-model="inkcreApi"
v-model="connectionUrl"
type="url"
class="flex-1 px-2 py-1 border border-border rounded-none bg-surface"
class="w-full px-2 py-1 border border-border bg-surface"
placeholder="https://..."
/>
</div>
<button type="button" @click="applyConnection()" class="px-3 py-1 border border-border">
保存并使用连接
</button>
<p v-if="apiConnectionIssue" role="alert">{{ apiConnectionIssue }}</p>
<p v-if="connectionFeedback" role="status">{{ connectionFeedback }}</p>
</fieldset>

<!-- LLM Provider Configuration -->
<div class="space-y-2">
Expand Down
Loading
Loading