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
8 changes: 8 additions & 0 deletions apps/buddy/electron/main/config/LexoraConfigStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Emitter } from '../../../shared/events/Emitter'
import { copyEventSnapshot } from '../../../shared/events/eventSnapshot'
import { extensionAuthorSchema } from '../../../shared/extensions/extensionIdentity'
import { DEFAULT_PROXY_SETTINGS, proxySettingsSchema } from '../../../shared/network/proxySettings'
import { BUDDY_PERMISSION_MODES } from '../../../shared/permissions/permissionMode'
import { DEFAULT_RUNTIME_PREFERENCES, runtimePreferencesSchema } from '../../../shared/runtime/runtimePreferences'
import { keybindingsSchema } from '../../../shared/shortcuts/keybindingSchema'
import { DEFAULT_DESKTOP_CHAT_PREFERENCES, DESKTOP_CHAT_OUTLINE_POSITIONS, DESKTOP_CHAT_WELCOME_VARIANT_IDS, DESKTOP_PROFILE_AVATAR_MAX_DATA_URL_LENGTH, DESKTOP_TASK_SIDEBAR_SECTIONS } from '../../shared/desktopApi'
Expand Down Expand Up @@ -43,6 +44,7 @@ const desktopConfigSchema = z.object({
background_close_notice_shown: z.boolean().default(false),
chat: z.object({
outline_position: z.enum(DESKTOP_CHAT_OUTLINE_POSITIONS).default(DEFAULT_DESKTOP_CHAT_PREFERENCES.outlinePosition),
permission_mode: z.enum(BUDDY_PERMISSION_MODES).default(DEFAULT_DESKTOP_CHAT_PREFERENCES.permissionMode),
welcome: z.enum(['none', 'random', ...DESKTOP_CHAT_WELCOME_VARIANT_IDS]).default(DEFAULT_DESKTOP_CHAT_PREFERENCES.welcome),
}).passthrough().prefault({}),
context_panel_mode: z.enum(['task', 'independent']).default('task'),
Expand Down Expand Up @@ -74,6 +76,7 @@ const desktopConfigSchema = z.object({
plugin_author: '',
chat: {
outline_position: DEFAULT_DESKTOP_CHAT_PREFERENCES.outlinePosition,
permission_mode: DEFAULT_DESKTOP_CHAT_PREFERENCES.permissionMode,
welcome: DEFAULT_DESKTOP_CHAT_PREFERENCES.welcome,
},
context_panel_mode: 'task',
Expand Down Expand Up @@ -288,6 +291,7 @@ function decodeConfig(value: unknown): LexoraConfig {
pluginAuthor: config.desktop.plugin_author,
chat: {
outlinePosition: config.desktop.chat.outline_position,
permissionMode: config.desktop.chat.permission_mode,
welcome: config.desktop.chat.welcome,
},
contextPanelMode: config.desktop.context_panel_mode,
Expand Down Expand Up @@ -337,6 +341,7 @@ function encodeConfig(config: LexoraConfig) {
plugin_author: config.desktop.pluginAuthor,
chat: {
outline_position: config.desktop.chat.outlinePosition,
permission_mode: config.desktop.chat.permissionMode,
welcome: config.desktop.chat.welcome,
},
context_panel_mode: config.desktop.contextPanelMode,
Expand Down Expand Up @@ -382,6 +387,9 @@ function mergeConfig(current: LexoraConfig, patch: LexoraConfigPatch): LexoraCon
chat: {
...current.desktop.chat,
...patch.desktop?.chat,
permissionMode: z.enum(BUDDY_PERMISSION_MODES).parse(
patch.desktop?.chat?.permissionMode ?? current.desktop.chat.permissionMode,
),
},
keybindings: keybindingsSchema.parse(patch.desktop?.keybindings ?? current.desktop.keybindings),
taskSidebar: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,37 @@ describe('lexoraConfigStore', () => {
await mkdir(dirname(configPath), { recursive: true })
await writeFile(configPath, '[desktop]\ntheme = "dark"\nwelcome_variant = "writing"\n[custom]\nkeep = true\n')
const original = await store.read()
expect(original.desktop.chat).toEqual({ outlinePosition: 'top-right', welcome: 'random' })
expect(original.desktop.chat).toEqual({ outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'random' })
await store.update({ desktop: { chat: { welcome: 'none' } } })
for (const outlinePosition of DESKTOP_CHAT_OUTLINE_POSITIONS) {
await store.update({ desktop: { chat: { outlinePosition } } })
const restored = await new LexoraConfigStore({ configPath }).read()
expect(restored).toEqual({
...original,
desktop: { ...original.desktop, chat: { outlinePosition, welcome: 'none' } },
desktop: { ...original.desktop, chat: { outlinePosition, permissionMode: 'policy_approval', welcome: 'none' } },
})
expect(await readFile(configPath, 'utf8')).toContain('keep = true')
}
})

it('persists permission mode, defaults legacy profiles, and rejects invalid values without a partial write', async () => {
const { configPath, store } = await createConfigStore()
await mkdir(dirname(configPath), { recursive: true })
await writeFile(configPath, '[desktop.chat]\nwelcome = "random"\n')
expect((await store.read()).desktop.chat.permissionMode).toBe('policy_approval')

await store.update({ desktop: { chat: { permissionMode: 'full_access' } } })
expect(await readFile(configPath, 'utf8')).toContain('permission_mode = "full_access"')
expect((await new LexoraConfigStore({ configPath }).read()).desktop.chat.permissionMode).toBe('full_access')

const saved = await readFile(configPath, 'utf8')
await expect(store.update({ desktop: { chat: { permissionMode: 'invalid' as never } } })).rejects.toThrow()
expect(await readFile(configPath, 'utf8')).toBe(saved)

await writeFile(configPath, '[desktop.chat]\npermission_mode = "invalid"\n')
await expect(store.read()).rejects.toThrow()
})

it('migrates browser preferences with defaults and preserves unrelated settings across updates', async () => {
const { configPath, store } = await createConfigStore()
await mkdir(dirname(configPath), { recursive: true })
Expand Down Expand Up @@ -139,7 +157,7 @@ describe('lexoraConfigStore', () => {
it('updates only requested settings and writes a private TOML file atomically', async () => {
const { configPath, store } = await createConfigStore()
await expect(store.read()).resolves.toMatchObject({
desktop: { theme: 'system', chat: { outlinePosition: 'top-right', welcome: 'random' } },
desktop: { theme: 'system', chat: { outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'random' } },
pet: { alwaysOnTop: true, enabled: true, rememberPosition: true },
})

Expand All @@ -157,7 +175,7 @@ describe('lexoraConfigStore', () => {
keybindings: {},
backgroundCloseNoticeShown: false,
pluginAuthor: '',
chat: { outlinePosition: 'top-right', welcome: 'writing' },
chat: { outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'writing' },
taskSidebarPinnedItems: [],
taskSidebar: {
collapsed: false,
Expand Down Expand Up @@ -300,7 +318,7 @@ describe('lexoraConfigStore', () => {
await writeFile(configPath, '[desktop.chat]\nwelcome = "none"\nfuture = true\n')

await expect(store.read()).resolves.toMatchObject({
desktop: { chat: { outlinePosition: 'top-right', welcome: 'none' } },
desktop: { chat: { outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'none' } },
})

await store.update({ desktop: { theme: 'dark' } })
Expand Down
5 changes: 3 additions & 2 deletions apps/buddy/electron/shared/__tests__/desktopApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { feedbackIssueInputSchema, lexoraConfigPatchSchema, releasePageInputSche
describe('desktop Preload API contract', () => {
it('rejects unknown outline positions before updating the application configuration', () => {
expect(() => lexoraConfigPatchSchema.parse({ desktop: { chat: { outlinePosition: 'center' } } })).toThrow()
expect(lexoraConfigPatchSchema.parse({ desktop: { chat: { outlinePosition: 'bottom-left', welcome: 'none' } } }))
.toEqual({ desktop: { chat: { outlinePosition: 'bottom-left', welcome: 'none' } } })
expect(lexoraConfigPatchSchema.parse({ desktop: { chat: { outlinePosition: 'bottom-left', permissionMode: 'full_access', welcome: 'none' } } }))
.toEqual({ desktop: { chat: { outlinePosition: 'bottom-left', permissionMode: 'full_access', welcome: 'none' } } })
expect(() => lexoraConfigPatchSchema.parse({ desktop: { chat: { permissionMode: 'invalid' } } })).toThrow()
})

it('accepts release pages from the canonical repository', () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/buddy/electron/shared/desktopApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { BuddyCapabilities } from '../../shared/platform'
import type { BuddyRuntimeProfile } from '../../shared/runtime/profile'
import type { DesktopCommandId, DesktopPlatform } from './desktopCommands'
import type { LocalChatApi } from './localChatApi'
import { BUDDY_DEFAULT_PERMISSION_MODE } from '../../shared/permissions/permissionMode'

export const DESKTOP_IPC_CHANNELS = {
workbenchRead: 'lexora:workbench:read',
Expand Down Expand Up @@ -148,11 +149,13 @@ export type DesktopChatOutlinePosition = typeof DESKTOP_CHAT_OUTLINE_POSITIONS[n

export interface DesktopChatPreferences {
outlinePosition: DesktopChatOutlinePosition
permissionMode: import('../../shared/permissions/permissionMode').BuddyPermissionMode
welcome: DesktopChatWelcomePreference
}

export const DEFAULT_DESKTOP_CHAT_PREFERENCES: Readonly<DesktopChatPreferences> = {
outlinePosition: 'top-right',
permissionMode: BUDDY_DEFAULT_PERMISSION_MODE,
welcome: 'random',
}

Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/electron/shared/desktopApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'zod'
import { browserPreferencesSchema } from '../../shared/browser/browserPreferences'
import { extensionAuthorSchema } from '../../shared/extensions/extensionIdentity'
import { proxySettingsSchema } from '../../shared/network/proxySettings'
import { BUDDY_PERMISSION_MODES } from '../../shared/permissions/permissionMode'
import { runtimePreferencesSchema } from '../../shared/runtime/runtimePreferences'
import { keybindingsSchema } from '../../shared/shortcuts/keybindingSchema'
import {
Expand Down Expand Up @@ -65,6 +66,7 @@ export const lexoraConfigPatchSchema: z.ZodType<LexoraConfigPatch> = z.object({
backgroundCloseNoticeShown: z.boolean().optional(),
chat: z.object({
outlinePosition: z.enum(DESKTOP_CHAT_OUTLINE_POSITIONS).optional(),
permissionMode: z.enum(BUDDY_PERMISSION_MODES).optional(),
welcome: z.enum(['none', 'random', ...DESKTOP_CHAT_WELCOME_VARIANT_IDS]).optional(),
}).strict().optional(),
contextPanelMode: z.enum(['task', 'independent']).optional(),
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/shared/permissions/permissionMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const BUDDY_PERMISSION_MODES = [

export type BuddyPermissionMode = typeof BUDDY_PERMISSION_MODES[number]

export const BUDDY_DEFAULT_PERMISSION_MODE: BuddyPermissionMode = 'policy_approval'

export interface BuddyPermissionSettings {
approvalPolicy: BuddyApprovalPolicy
executionProfile: BuddyExecutionProfile
Expand Down
9 changes: 8 additions & 1 deletion apps/buddy/src/i18n/locales/en-US/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import type zhCN from '../zh-CN/settings'

export default {
'desktop.settings.category.runtime': 'Runtime',
'desktop.settings.categoryDescription.runtime': 'Manage context and model request behavior.',
'desktop.settings.categoryDescription.runtime': 'Manage permissions for new tasks, context, and model request behavior.',
'desktop.settings.runtime.taskPermissions': 'Task permissions',
'desktop.settings.runtime.defaultPermissionMode': 'New task permissions',
'desktop.settings.runtime.defaultPermissionModeDescription': 'New tasks start with the selected permissions. Existing tasks, including unsent drafts, keep their current settings.',
'desktop.settings.runtime.fullAccessDefaultTitle': 'Use full access by default for new tasks?',
'desktop.settings.runtime.fullAccessDefaultDescription': 'New tasks will start with local access to your files, credentials, and network. The sandbox cannot restrict what arbitrary commands do; some sensitive operations will still ask for confirmation.',
'desktop.settings.runtime.fullAccessDefaultAcknowledgement': 'I understand that full access will apply to every new task by default',
'desktop.settings.runtime.fullAccessDefaultConfirm': 'Use as default',
'desktop.settings.runtime.context': 'Context',
'desktop.settings.runtime.cacheWarming': 'Cache warming',
'desktop.settings.runtime.cacheWarmingDescription': 'Maintain prompt caches during active tasks when the model supports it and the expected benefit covers the cost. Additional usage is included in usage reports. Stops when the task ends.',
Expand Down
9 changes: 8 additions & 1 deletion apps/buddy/src/i18n/locales/zh-CN/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export default {
'desktop.settings.category.runtime': '运行时',
'desktop.settings.categoryDescription.runtime': '管理上下文与模型调用策略。',
'desktop.settings.categoryDescription.runtime': '管理新任务权限、上下文与模型调用策略。',
'desktop.settings.runtime.taskPermissions': '任务权限',
'desktop.settings.runtime.defaultPermissionMode': '新任务权限',
'desktop.settings.runtime.defaultPermissionModeDescription': '新建任务时使用所选权限;已有任务(包括未发送的草稿)保持当前设置。',
'desktop.settings.runtime.fullAccessDefaultTitle': '将完全访问设为新任务默认权限?',
'desktop.settings.runtime.fullAccessDefaultDescription': '之后新建的任务将默认以你的本机权限访问文件、凭据和网络。任意命令的实际行为无法由沙盒限制;部分敏感操作仍会请求确认。',
'desktop.settings.runtime.fullAccessDefaultAcknowledgement': '我已了解完全访问会默认应用于每个新任务',
'desktop.settings.runtime.fullAccessDefaultConfirm': '设为默认权限',
'desktop.settings.runtime.context': '上下文',
'desktop.settings.runtime.cacheWarming': '缓存预热',
'desktop.settings.runtime.cacheWarmingDescription': '任务执行期间按需维护模型的提示词缓存,仅在支持的模型上且预计有收益时执行。会产生额外用量,计入使用情况;任务结束后停止。',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,63 +1,66 @@
<script setup lang="ts">
import type { BuddyLocale } from '@/i18n/buddyI18n'
import { NButton, NCheckbox, NModal } from 'naive-ui'
import { shallowRef, watch } from 'vue'
import { useBuddyI18n } from '@/i18n/buddyI18n'

const props = defineProps<{
language: BuddyLocale
show: boolean
}>()
interface FullAccessConfirmationText {
acknowledgement: string
cancelLabel: string
confirmLabel: string
description: string
title: string
}

defineProps<{
text: FullAccessConfirmationText
}>()
const emit = defineEmits<{
cancel: []
confirm: []
}>()

const show = defineModel<boolean>('show', { required: true })
const acknowledged = shallowRef(false)
const { t } = useBuddyI18n(() => props.language)

watch(() => props.show, (show) => {
if (show)
watch(show, (open) => {
if (open)
acknowledged.value = false
})

function cancel() {
emit('cancel')
show.value = false
}

function confirm() {
if (acknowledged.value)
emit('confirm')
if (!acknowledged.value)
return
show.value = false
emit('confirm')
}
</script>

<template>
<NModal
:show="show"
v-model:show="show"
preset="dialog"
type="error"
class="desktop-full-access-confirmation"
:style="{ width: 'min(25rem, calc(100vw - 2rem))' }"
:closable="false"
:mask-closable="false"
:title="t('desktop.chat.executionProfileFullConfirmTitle')"
@update:show="!$event && cancel()"
:title="text.title"
>
<p class="desktop-full-access-confirmation__description">
{{ t('desktop.chat.executionProfileFullConfirmDescription') }}
{{ text.description }}
</p>
<NCheckbox v-model:checked="acknowledged" class="desktop-full-access-confirmation__acknowledgement">
{{ t('desktop.chat.executionProfileFullConfirmAcknowledgement') }}
{{ text.acknowledgement }}
</NCheckbox>

<template #action>
<div class="desktop-full-access-confirmation__actions">
<NButton @click="cancel">
{{ t('common.cancel') }}
{{ text.cancelLabel }}
</NButton>
<NButton type="error" :disabled="!acknowledged" @click="confirm">
{{ t('desktop.chat.executionProfileAllowFull') }}
{{ text.confirmLabel }}
</NButton>
</div>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ const permissionOptions = [
] as const

const { t } = useBuddyI18n(() => props.language)
const fullAccessConfirmationText = computed(() => ({
acknowledgement: t('desktop.chat.executionProfileFullConfirmAcknowledgement'),
cancelLabel: t('common.cancel'),
confirmLabel: t('desktop.chat.executionProfileAllowFull'),
description: t('desktop.chat.executionProfileFullConfirmDescription'),
title: t('desktop.chat.executionProfileFullConfirmTitle'),
}))
const isBackground = computed(() => props.sessionMode === 'automation_background')
const availableOptions = computed(() => permissionOptions.filter(
option => !isBackground.value || option.value !== 'manual_approval',
Expand Down Expand Up @@ -92,7 +99,6 @@ function selectMode(value: BuddyPermissionMode) {
}

function confirmFullAccess() {
confirmationOpen.value = false
emit('updatePermissionMode', 'full_access')
}

Expand Down Expand Up @@ -196,9 +202,8 @@ async function confirmSetup() {
</NPopover>

<DesktopFullAccessConfirmationDialog
:language="language"
:show="confirmationOpen"
@cancel="confirmationOpen = false"
v-model:show="confirmationOpen"
:text="fullAccessConfirmationText"
@confirm="confirmFullAccess"
/>

Expand Down
1 change: 1 addition & 0 deletions apps/buddy/src/modules/prompt-input/ui.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as DesktopChatComposerFrame } from './components/DesktopChatComposerFrame.vue'
export { default as DesktopFullAccessConfirmationDialog } from './components/DesktopFullAccessConfirmationDialog.vue'
export { default as DesktopPermissionModeSelector } from './components/DesktopPermissionModeSelector.vue'
export { ChatComposerDocument, ChatComposerPromptDirective, ChatComposerResourceClipboard, ChatComposerResourceReference, insertChatComposerResources, insertResolvedChatComposerResource, moveChatComposerResourceSelection, removeChatComposerPanelResource, removeChatComposerResource, setChatComposerPanelResources } from './editor/chatComposerResourceEditing'
Loading