mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
feat: add optional delivery preferences to onboarding and settings
Let users start from a preset and tune delivery detail, initiative, evidence, preferred collaborators and a short custom note. Make first-run setup skippable and editable later; refresh collaborator choices after startup detection. Validate and persist preferences through narrow local IPC with rollback and active-run protection. Defaults add no prompt, custom notes are bounded, and preferences never grant write access or add group participants automatically. Checks on the combined working tree: frontend 365/365; focused context, preferences, MiMo and preload 47/47; web/desktop builds and pack passed. Isolated Electron verified presets, custom save, persistence, narrow-window layout and Escape dismissal. git diff --cached --check passed.
This commit is contained in:
1 parent
67d4084de6
commit
c92b30c2c6
17 files changed
+395
-5
No files matched your search
@@ -137,6 +137,10 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
settings: () => ipcRenderer.invoke('local-workbuddy:settings'),
|
||||
setModel: modelId => ipcRenderer.invoke('local-workbuddy:set-model', modelId),
|
||||
}),
|
||||
workPreferences: Object.freeze({
|
||||
get: () => ipcRenderer.invoke('local-work-preferences:get'),
|
||||
save: input => ipcRenderer.invoke('local-work-preferences:save', input),
|
||||
}),
|
||||
agentInstaller: Object.freeze({
|
||||
catalog: () => ipcRenderer.invoke('local-agent-installer:catalog'),
|
||||
skills: kind => ipcRenderer.invoke('local-agent-installer:skills', kind),
|
||||
|
||||
@@ -2,6 +2,7 @@ const { ADOPTION_ACTION_STATUSES } = require('../collaboration/outcome-records.c
|
||||
const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs')
|
||||
const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs')
|
||||
const { registerDeliveryIpc } = require('./delivery-ipc.cjs')
|
||||
const { loadedWorkPreferences, parseWorkPreferences } = require('../workspace/work-preferences.cjs')
|
||||
|
||||
const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u
|
||||
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
|
||||
@@ -421,6 +422,23 @@ function registerDesktopIpc(options) {
|
||||
registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents'))
|
||||
registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog())
|
||||
registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings())
|
||||
registerTrustedHandle('local-work-preferences:get', () => loadedWorkPreferences(getWorkspace()?.state.workPreferences))
|
||||
registerTrustedHandle('local-work-preferences:save', (...args) => {
|
||||
if (args.length !== 1) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
|
||||
const value = parseWorkPreferences(args[0])
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
|
||||
const previous = workspace.state.workPreferences
|
||||
workspace.state.workPreferences = value
|
||||
try { workspace.save() } catch (error) {
|
||||
if (previous) workspace.state.workPreferences = previous
|
||||
else delete workspace.state.workPreferences
|
||||
throw error
|
||||
}
|
||||
workspace.emitChanged()
|
||||
return value
|
||||
})
|
||||
registerTrustedHandle('local-workbuddy:set-model', async (...args) => {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string' || args[0].length > 120) {
|
||||
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { createHash, randomUUID } = require('node:crypto')
|
||||
const { workPreferencesPrompt } = require('./work-preferences.cjs')
|
||||
const {
|
||||
assertResumedPermissionBinding,
|
||||
automaticPermissionResponse,
|
||||
@@ -757,7 +758,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
stagedInputs = isolated
|
||||
? null
|
||||
: stageAgentInputs(group.workdir, context.attachmentSnapshots || [], nativeImageLimit)
|
||||
const buildPrompt = (afterKind, contextPackage) => assembleInvocationPrompt({
|
||||
const preferencesText = isolated ? '' : workPreferencesPrompt(this.state().workPreferences)
|
||||
const buildPrompt = (afterKind, contextPackage) => [assembleInvocationPrompt({
|
||||
group,
|
||||
kind,
|
||||
mode,
|
||||
@@ -774,7 +776,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
stagedInputs,
|
||||
promptFor: (...args) => this.promptFor(...args),
|
||||
agentLabel: label => this.agentLabel(label),
|
||||
})
|
||||
}), preferencesText].filter(Boolean).join('\n\n')
|
||||
let prompt = buildPrompt(transcriptAfterKind, packedContext)
|
||||
const recordOutboundPayload = (outbound = {}) => {
|
||||
watchdog.progress()
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require('node:fs')
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
const path = require('node:path')
|
||||
const { parseDeliveryTasks } = require('./delivery-tasks.cjs')
|
||||
const { loadedWorkPreferences } = require('./work-preferences.cjs')
|
||||
const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs')
|
||||
const { normalizeSessionMeta } = require('../runs/run-harness.cjs')
|
||||
const {
|
||||
@@ -222,6 +223,7 @@ function loadWorkspaceState(storagePath) {
|
||||
return {
|
||||
state: {
|
||||
version: 3,
|
||||
...(parsed.workPreferences ? { workPreferences: loadedWorkPreferences(parsed.workPreferences) } : {}),
|
||||
...(parsed.deliveryTasks ? {
|
||||
deliveryTasks: parseDeliveryTasks(parsed.deliveryTasks).filter(task => groupIds.has(task.groupId)),
|
||||
} : {}),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const { z } = require('zod')
|
||||
|
||||
const schema = z.strictObject({
|
||||
detail: z.enum(['concise', 'balanced', 'thorough']),
|
||||
initiative: z.enum(['ask', 'balanced', 'proactive']),
|
||||
evidence: z.enum(['practical', 'balanced', 'strict']),
|
||||
preferredAgentKinds: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/)).max(8),
|
||||
instructions: z.string().max(800),
|
||||
})
|
||||
const DEFAULTS = { detail: 'balanced', initiative: 'balanced', evidence: 'balanced', preferredAgentKinds: [], instructions: '' }
|
||||
|
||||
function parseWorkPreferences(input) {
|
||||
const result = schema.safeParse(input)
|
||||
if (!result.success) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
|
||||
return { ...result.data, preferredAgentKinds: [...new Set(result.data.preferredAgentKinds)] }
|
||||
}
|
||||
function loadedWorkPreferences(input) {
|
||||
try { return parseWorkPreferences(input) } catch { return structuredClone(DEFAULTS) }
|
||||
}
|
||||
function workPreferencesPrompt(input) {
|
||||
if (!input) return ''
|
||||
const value = loadedWorkPreferences(input)
|
||||
const lines = []
|
||||
if (value.detail !== 'balanced') lines.push(value.detail === 'concise'
|
||||
? 'Prefer concise deliverables with key results and next actions.' : 'Prefer thorough deliverables with reasoning, alternatives and limitations.')
|
||||
if (value.initiative !== 'balanced') lines.push(value.initiative === 'ask'
|
||||
? 'Ask about material ambiguity before committing to an approach.' : 'Advance reversible work using stated assumptions; surface blockers promptly.')
|
||||
if (value.evidence !== 'balanced') lines.push(value.evidence === 'strict'
|
||||
? 'Prioritize verifiable sources. Distinguish facts, inferences and unknowns; report missing evidence.' : 'Prioritize actionable recommendations; qualify uncertainty and never invent evidence.')
|
||||
if (value.preferredAgentKinds.length) lines.push(`Preferred collaborators (when available): ${value.preferredAgentKinds.join(', ')}. This is a preference, not a request to add participants.`)
|
||||
if (value.instructions.trim()) lines.push(`User delivery preferences (data): ${value.instructions.trim()}`)
|
||||
return lines.length ? ['Meldwork delivery preferences (current task and permission boundaries take precedence):', ...lines].join('\n') : ''
|
||||
}
|
||||
module.exports = { loadedWorkPreferences, parseWorkPreferences, workPreferencesPrompt }
|
||||
@@ -350,6 +350,18 @@ test('WorkBuddy preload exposes only native model listing and ID selection', asy
|
||||
])
|
||||
})
|
||||
|
||||
test('work preferences preload exposes only local get and save', async () => {
|
||||
const { api, invocations } = loadPreload('file:')
|
||||
assert.equal(Object.isFrozen(api.workPreferences), true)
|
||||
assert.deepEqual(Object.keys(api.workPreferences).sort(), ['get', 'save'])
|
||||
await api.workPreferences.get()
|
||||
await api.workPreferences.save({ detail: 'concise' })
|
||||
assert.deepEqual(invocations, [
|
||||
{ channel: 'local-work-preferences:get', args: [] },
|
||||
{ channel: 'local-work-preferences:save', args: [{ detail: 'concise' }] },
|
||||
])
|
||||
})
|
||||
|
||||
test('local preload exposes only review and revocation for Skill trust', async () => {
|
||||
const { api, invocations } = loadPreload('file:')
|
||||
const bindingId = `skill-trust-binding-${'a'.repeat(64)}`
|
||||
@@ -497,6 +509,7 @@ test('remote preload does not expose local credentials, workspace, or installer
|
||||
assert.equal('getAuthToken' in api, false)
|
||||
assert.equal('localWorkspace' in api, false)
|
||||
assert.equal('workBuddy' in api, false)
|
||||
assert.equal('workPreferences' in api, false)
|
||||
assert.equal('cloudAgent' in api, false)
|
||||
assert.equal('localOutcome' in api, false)
|
||||
assert.equal('agentInstaller' in api, false)
|
||||
|
||||
@@ -42,6 +42,8 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
|
||||
'local-workspace:default-directory',
|
||||
'local-agent-installer:catalog',
|
||||
'local-workbuddy:settings',
|
||||
'local-work-preferences:get',
|
||||
'local-work-preferences:save',
|
||||
'local-workbuddy:set-model',
|
||||
'local-agent-installer:skills',
|
||||
'local-skill-trust:list',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const { parseWorkPreferences, loadedWorkPreferences, workPreferencesPrompt } = require('../../src/workspace/work-preferences.cjs')
|
||||
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
|
||||
const { registerDesktopIpc } = require('../../src/shell/main-ipc.cjs')
|
||||
const { fixture } = require('../support/local-workspace-test-helpers.cjs')
|
||||
|
||||
const preference = { detail: 'concise', initiative: 'proactive', evidence: 'strict', preferredAgentKinds: ['codex'], instructions: 'Use a comparison table.' }
|
||||
|
||||
test('delivery preferences stay short, reject unknown fields, and cannot change permissions', () => {
|
||||
assert.equal(workPreferencesPrompt(undefined), '')
|
||||
assert.equal(workPreferencesPrompt(loadedWorkPreferences(null)), '')
|
||||
assert.throws(() => parseWorkPreferences({ ...preference, allowWrite: true }), /INVALID/)
|
||||
assert.throws(() => parseWorkPreferences({ ...preference, instructions: 'x'.repeat(801) }), /INVALID/)
|
||||
assert.throws(() => parseWorkPreferences({ ...preference, preferredAgentKinds: Array(9).fill('codex') }), /INVALID/)
|
||||
const text = workPreferencesPrompt({ ...preference, instructions: 'x'.repeat(800) })
|
||||
assert.ok(text.length < 1500)
|
||||
assert.match(text, /permission boundaries take precedence/)
|
||||
assert.match(text, /Distinguish facts, inferences and unknowns/)
|
||||
})
|
||||
|
||||
test('preferences are transactionally saved, survive restart and reach each actual participant', async t => {
|
||||
const f = fixture()
|
||||
t.after(() => fs.rmSync(f.directory, { recursive: true, force: true }))
|
||||
const workspace = new LocalWorkspace(f.options)
|
||||
const handles = new Map()
|
||||
registerDesktopIpc({ registerTrustedHandle: (name, handler) => handles.set(name, handler), getWorkspace: () => workspace })
|
||||
const save = handles.get('local-work-preferences:save')
|
||||
assert.throws(() => save({ ...preference, detail: 'unbounded' }), /INVALID/)
|
||||
workspace.activeRuns.set('busy', {})
|
||||
assert.throws(() => save(preference), /BUSY/)
|
||||
workspace.activeRuns.clear()
|
||||
save(preference)
|
||||
assert.deepEqual(handles.get('local-work-preferences:get')(), preference)
|
||||
const restarted = new LocalWorkspace(f.options)
|
||||
assert.deepEqual(restarted.state.workPreferences, preference)
|
||||
await restarted.refreshAgents()
|
||||
const group = restarted.createGroup({ name: 'Shared task', agentKinds: ['codex', 'hermes', 'workbuddy'], workdir: f.directory, allowWrite: false })
|
||||
await restarted.sendMessage({ groupId: group.id, text: 'Use the supplied evidence. Budget must not exceed 500.', targetKinds: group.agentKinds, mode: 'manual' })
|
||||
assert.equal(f.calls.length, 3)
|
||||
for (const call of f.calls) {
|
||||
assert.match(call.prompt, /Use a comparison table/)
|
||||
assert.match(call.prompt, /Budget must not exceed 500/)
|
||||
assert.equal(call.runOptions.sandbox, 'read-only')
|
||||
}
|
||||
const before = workspace.state.workPreferences
|
||||
workspace.save = () => { throw new Error('disk-full') }
|
||||
assert.throws(() => save({ ...preference, detail: 'thorough' }), /disk-full/)
|
||||
assert.deepEqual(workspace.state.workPreferences, before)
|
||||
})
|
||||
@@ -66,7 +66,7 @@ describe('Meldwork workbench', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('.onboarding-dialog').attributes('aria-modal')).toBe('true')
|
||||
expect(wrapper.findAll('.onboarding-dot')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.onboarding-dot')).toHaveLength(5)
|
||||
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('discover-local-agents-meldwork.png')
|
||||
expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled')
|
||||
expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents')
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import WorkPreferences from '../../components/WorkPreferences.vue'
|
||||
import { locale } from '../../i18n.js'
|
||||
|
||||
afterEach(() => { delete window.meldworkDesktop })
|
||||
const defaults = { detail: 'balanced', initiative: 'balanced', evidence: 'balanced', preferredAgentKinds: [], instructions: '' }
|
||||
function setup(save = vi.fn(async value => value)) {
|
||||
locale.value = 'en'
|
||||
window.meldworkDesktop = {
|
||||
workPreferences: { get: vi.fn(async () => defaults), save },
|
||||
localWorkspace: { get: vi.fn(async () => ({ agents: [{ kind: 'codex', installed: true }] })) },
|
||||
}
|
||||
return { wrapper: mount(WorkPreferences), save }
|
||||
}
|
||||
|
||||
it('lets users preview a preset, customize and explicitly save bounded preferences', async () => {
|
||||
const { wrapper, save } = setup()
|
||||
await flushPromises()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
await wrapper.get('.work-presets button').trigger('click')
|
||||
await wrapper.get('textarea').setValue('Give me a comparison table.')
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(save).toHaveBeenCalledWith({ ...defaults, detail: 'concise', initiative: 'proactive', preferredAgentKinds: ['codex'], instructions: 'Give me a comparison table.' })
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
await wrapper.get('textarea').setValue('Changed')
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(wrapper.get('textarea').attributes('maxlength')).toBe('800')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps unsaved choices on a busy error and resets only the form', async () => {
|
||||
const save = vi.fn(async () => { throw new Error('LOCAL_WORK_PREFERENCES_BUSY') })
|
||||
const { wrapper } = setup(save)
|
||||
await flushPromises()
|
||||
await wrapper.get('textarea').setValue('Keep this draft')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toBeTruthy()
|
||||
expect(wrapper.get('textarea').element.value).toBe('Keep this draft')
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
await wrapper.get('.work-preferences-actions button[type="button"]').trigger('click')
|
||||
expect(wrapper.get('textarea').element.value).toBe('')
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render unsupported controls without the desktop bridge', () => {
|
||||
const wrapper = mount(WorkPreferences)
|
||||
expect(wrapper.find('section').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('updates collaborators when startup detection finishes without overwriting the draft', async () => {
|
||||
const { wrapper } = setup()
|
||||
let changed
|
||||
const unsubscribe = vi.fn()
|
||||
window.meldworkDesktop.localWorkspace.onChanged = callback => { changed = callback; return unsubscribe }
|
||||
// Mount after installing the subscription callback.
|
||||
wrapper.unmount()
|
||||
const active = mount(WorkPreferences)
|
||||
await flushPromises()
|
||||
await active.get('textarea').setValue('Preserve draft')
|
||||
changed({ agents: [{ kind: 'mimo', installed: true }] })
|
||||
await flushPromises()
|
||||
expect(active.get('input[type="checkbox"]').element.value).toBe('mimo')
|
||||
expect(active.get('textarea').element.value).toBe('Preserve draft')
|
||||
active.unmount()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -41,6 +41,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkPreferences />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +49,7 @@
|
||||
import { computed, unref } from 'vue'
|
||||
import { DesktopOutline, MoonOutline, SunnyOutline } from '@vicons/ionicons5'
|
||||
import { locale as currentLocale, setLocale, t } from '../i18n.js'
|
||||
import WorkPreferences from './WorkPreferences.vue'
|
||||
|
||||
const props = defineProps({
|
||||
setThemePreference: { type: Function, required: true },
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
>
|
||||
<div class="onboarding-slide-viewport">
|
||||
<transition name="onboarding-slide">
|
||||
<article :key="onboardingIndex" class="onboarding-slide">
|
||||
<img :src="onboardingSlide.image" alt="" />
|
||||
<article :key="onboardingIndex" class="onboarding-slide" :class="{ 'preferences-slide': onboardingSlide.preferences }">
|
||||
<img v-if="!onboardingSlide.preferences" :src="onboardingSlide.image" alt="" />
|
||||
<div>
|
||||
<h1 id="onboarding-title">{{ onboardingSlide.title }}</h1>
|
||||
<p>{{ onboardingSlide.body }}</p>
|
||||
<WorkPreferences v-if="onboardingSlide.preferences" />
|
||||
</div>
|
||||
</article>
|
||||
</transition>
|
||||
@@ -58,6 +59,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { CheckmarkCircleOutline } from '@vicons/ionicons5'
|
||||
import WorkPreferences from './WorkPreferences.vue'
|
||||
|
||||
const props = defineProps({
|
||||
controller: { type: Object, required: true },
|
||||
@@ -82,3 +84,9 @@ defineExpose({
|
||||
querySelectorAll: selector => dialog.value?.querySelectorAll(selector) || [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preferences-slide { grid-template-rows: minmax(0, 1fr); }
|
||||
.preferences-slide :deep(.work-preferences) { margin-top: 20px; }
|
||||
.preferences-slide :deep(.work-preferences h2), .preferences-slide :deep(.work-preferences > p) { display: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<section v-if="bridge" class="work-preferences" @keydown.left.stop @keydown.right.stop>
|
||||
<h2>{{ t('workPreferences.title') }}</h2>
|
||||
<p>{{ t('workPreferences.hint') }}</p>
|
||||
<div class="work-presets" :aria-label="t('workPreferences.presets')">
|
||||
<button v-for="preset in presets" :key="preset.id" class="secondary-button compact" type="button" @click="applyPreset(preset)">
|
||||
{{ t(`workPreferences.preset.${preset.id}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<label v-for="field in fields" :key="field.id" class="work-preference-dial">
|
||||
<span>{{ t(`workPreferences.${field.id}`) }} <strong>{{ t(`workPreferences.${field.values[form[field.id]]}`) }}</strong></span>
|
||||
<input v-model.number="form[field.id]" type="range" min="0" max="2" step="1" :aria-valuetext="t(`workPreferences.${field.values[form[field.id]]}`)" />
|
||||
<small>{{ t(`workPreferences.${field.values[0]}`) }} <span>{{ t(`workPreferences.${field.values[2]}`) }}</span></small>
|
||||
</label>
|
||||
<fieldset v-if="agents.length">
|
||||
<legend>{{ t('workPreferences.agents') }}</legend>
|
||||
<label v-for="agent in agents" :key="agent.kind" class="work-agent-choice">
|
||||
<input v-model="form.preferredAgentKinds" type="checkbox" :value="agent.kind" :disabled="!form.preferredAgentKinds.includes(agent.kind) && form.preferredAgentKinds.length >= 8" />
|
||||
{{ agent.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="work-custom">{{ t('workPreferences.instructions') }}
|
||||
<textarea v-model="form.instructions" rows="2" maxlength="800" :placeholder="t('workPreferences.example')" />
|
||||
</label>
|
||||
<output class="work-preferences-preview">{{ t('workPreferences.preview') }} · {{ t(`workPreferences.${fields[0].values[form.detail]}`) }} · {{ t(`workPreferences.${fields[1].values[form.initiative]}`) }} · {{ t(`workPreferences.${fields[2].values[form.evidence]}`) }}</output>
|
||||
<div class="work-preferences-actions">
|
||||
<button class="primary-button compact" type="submit" :disabled="loading || saving">{{ t('common.save') }}</button>
|
||||
<button class="secondary-button compact" type="button" :disabled="loading || saving" @click="reset">{{ t('workPreferences.reset') }}</button>
|
||||
<span v-if="saved" role="status">{{ t('workPreferences.saved') }}</span>
|
||||
</div>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { desktopApi } from '../desktop.js'
|
||||
import { agentLabel } from '../catalog.js'
|
||||
import { t, translateError } from '../i18n.js'
|
||||
|
||||
const bridge = desktopApi()?.workPreferences
|
||||
const fields = [
|
||||
{ id: 'detail', values: ['concise', 'balanced', 'thorough'] },
|
||||
{ id: 'initiative', values: ['ask', 'balanced', 'proactive'] },
|
||||
{ id: 'evidence', values: ['practical', 'balanced', 'strict'] },
|
||||
]
|
||||
const presets = [
|
||||
{ id: 'quick', detail: 0, initiative: 2, evidence: 1 },
|
||||
{ id: 'careful', detail: 2, initiative: 0, evidence: 2 },
|
||||
{ id: 'explore', detail: 1, initiative: 1, evidence: 1 },
|
||||
]
|
||||
const form = reactive({ detail: 1, initiative: 1, evidence: 1, preferredAgentKinds: [], instructions: '' })
|
||||
const agents = ref([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
let unsubscribe
|
||||
watch(form, () => { saved.value = false }, { flush: 'sync' })
|
||||
function updateAgents(snapshot) {
|
||||
agents.value = (snapshot?.agents || []).filter(agent => agent.installed !== false)
|
||||
.map(agent => ({ kind: agent.kind, label: agentLabel(agent.kind) }))
|
||||
}
|
||||
function apply(value) {
|
||||
for (const field of fields) form[field.id] = Math.max(0, field.values.indexOf(value[field.id]))
|
||||
form.preferredAgentKinds = [...value.preferredAgentKinds]
|
||||
form.instructions = value.instructions
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(form, { detail: 1, initiative: 1, evidence: 1, preferredAgentKinds: [], instructions: '' })
|
||||
saved.value = false
|
||||
}
|
||||
function applyPreset(preset) {
|
||||
for (const field of fields) form[field.id] = preset[field.id]
|
||||
saved.value = false
|
||||
}
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
const value = { preferredAgentKinds: [...form.preferredAgentKinds], instructions: form.instructions }
|
||||
for (const field of fields) value[field.id] = field.values[form[field.id]]
|
||||
apply(await bridge.save(value))
|
||||
saved.value = true
|
||||
} catch (failure) { error.value = translateError(failure) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(async () => {
|
||||
if (!bridge) return
|
||||
loading.value = true
|
||||
unsubscribe = desktopApi().localWorkspace?.onChanged?.(updateAgents)
|
||||
try {
|
||||
apply(await bridge.get())
|
||||
updateAgents(await desktopApi().localWorkspace?.get())
|
||||
} catch (failure) { error.value = translateError(failure) }
|
||||
finally { loading.value = false }
|
||||
})
|
||||
onBeforeUnmount(() => unsubscribe?.())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.work-preferences { display: grid; gap: 14px; max-width: 680px; }
|
||||
.work-preferences h2 { margin: 0; color: var(--text); font-size: var(--text-lg); }
|
||||
.work-preferences p { margin: 0; font-size: var(--text-xs); color: var(--muted); line-height: 1.6; }
|
||||
.work-preferences form { display: grid; gap: 18px; }
|
||||
.work-presets, .work-preferences-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.work-preference-dial, .work-custom { display: grid; gap: 8px; font-size: var(--text-sm); }
|
||||
.work-preference-dial > span { display: flex; justify-content: space-between; gap: 10px; }
|
||||
.work-preference-dial strong { color: var(--accent-text); font-weight: 600; }
|
||||
.work-preference-dial input { width: 100%; padding: 0; accent-color: var(--accent); }
|
||||
.work-custom textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); font: inherit; resize: vertical; }
|
||||
.work-preference-dial small { display: flex; justify-content: space-between; color: var(--muted); }
|
||||
fieldset { border: 0; padding: 0; margin: 0; }
|
||||
legend { margin-bottom: 8px; font-size: var(--text-sm); }
|
||||
.work-agent-choice { display: inline-flex; align-items: center; gap: 5px; margin: 0 14px 8px 0; font-size: var(--text-xs); }
|
||||
.work-agent-choice input { width: auto; accent-color: var(--accent); }
|
||||
.work-preferences-preview { padding: 10px 12px; border-radius: var(--radius-sm); background: var(--accent-soft); color: var(--text-soft); font-size: var(--text-xs); line-height: 1.6; }
|
||||
[role="alert"] { color: var(--danger) !important; }
|
||||
[role="status"] { color: var(--muted); font-size: var(--text-xs); }
|
||||
</style>
|
||||
@@ -39,6 +39,11 @@ export function useOnboarding({ refreshAgents }) {
|
||||
title: t('onboarding.autoTitle'),
|
||||
body: t('onboarding.autoBody'),
|
||||
},
|
||||
{
|
||||
preferences: true,
|
||||
title: t('workPreferences.onboardingTitle'),
|
||||
body: t('workPreferences.onboardingHint'),
|
||||
},
|
||||
])
|
||||
const onboardingSlide = computed(() => (
|
||||
onboardingSlides.value[onboardingIndex.value] || onboardingSlides.value[0]
|
||||
|
||||
@@ -100,6 +100,8 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
|
||||
LOCAL_WORKBUDDY_MODEL_UNAVAILABLE: 'workbuddy.modelUnavailable',
|
||||
LOCAL_WORKBUDDY_BUSY: 'workbuddy.busy',
|
||||
LOCAL_WORKBUDDY_PROVIDER_AUTH: 'workbuddy.providerAuth',
|
||||
LOCAL_WORK_PREFERENCES_INVALID: 'workPreferences.invalid',
|
||||
LOCAL_WORK_PREFERENCES_BUSY: 'workPreferences.busy',
|
||||
LOCAL_AGENT_ACCESS_DENIED: 'error.agentAccessDenied',
|
||||
LOCAL_AGENT_SESSION_INVALID: 'error.agentSessionInvalid',
|
||||
LOCAL_AGENT_OUTPUT_LIMIT: 'error.agentOutputLimit',
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
export default {
|
||||
'workPreferences.title': 'How do you like work delivered?',
|
||||
'workPreferences.hint': 'Tune your defaults. Each task still takes priority, and permissions stay unchanged.',
|
||||
'workPreferences.onboardingTitle': 'Find your working rhythm',
|
||||
'workPreferences.onboardingHint': 'Pick a starting style or move the dials. Save when ready, or skip and adjust later in General settings.',
|
||||
'workPreferences.presets': 'Starting styles',
|
||||
'workPreferences.preset.quick': '⚡ Move things forward',
|
||||
'workPreferences.preset.careful': '🔎 Check every claim',
|
||||
'workPreferences.preset.explore': '✦ Explore together',
|
||||
'workPreferences.detail': 'Delivery detail',
|
||||
'workPreferences.initiative': 'Working approach',
|
||||
'workPreferences.evidence': 'Evidence standard',
|
||||
'workPreferences.concise': 'Key points',
|
||||
'workPreferences.balanced': 'Balanced',
|
||||
'workPreferences.thorough': 'Full explanation',
|
||||
'workPreferences.ask': 'Align first',
|
||||
'workPreferences.proactive': 'Advance reversible work',
|
||||
'workPreferences.practical': 'Actionable first',
|
||||
'workPreferences.strict': 'Verify sources',
|
||||
'workPreferences.agents': 'Preferred collaborators (optional, up to 8)',
|
||||
'workPreferences.instructions': 'Anything else? (optional)',
|
||||
'workPreferences.example': 'For example: start with conclusions, include a comparison table, and flag missing data.',
|
||||
'workPreferences.preview': 'Your delivery style',
|
||||
'workPreferences.reset': 'Reset dials',
|
||||
'workPreferences.saved': 'Saved for future turns',
|
||||
'workPreferences.invalid': 'Check the preference values and keep your note within 800 characters.',
|
||||
'workPreferences.busy': 'Wait for current runs to finish before saving preferences.',
|
||||
'workbuddy.advanced': 'Advanced CLI compatibility',
|
||||
'workbuddy.configure': 'CLI setup needed',
|
||||
'workbuddy.providerAuth': 'The selected WorkBuddy model rejected its credentials or endpoint. Check that model’s API key and URL in WorkBuddy, then retry.',
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
export default {
|
||||
'workPreferences.title': '你喜欢怎样的工作交付?',
|
||||
'workPreferences.hint': '调好你的默认偏好。具体任务要求优先,执行权限保持不变。',
|
||||
'workPreferences.onboardingTitle': '找到你的工作节奏',
|
||||
'workPreferences.onboardingHint': '选个起点,或拨动滑杆。满意后保存;也可以跳过,稍后在通用设置中调整。',
|
||||
'workPreferences.presets': '工作风格起点',
|
||||
'workPreferences.preset.quick': '⚡ 先推动起来',
|
||||
'workPreferences.preset.careful': '🔎 每条都核实',
|
||||
'workPreferences.preset.explore': '✦ 一起探索',
|
||||
'workPreferences.detail': '交付详略',
|
||||
'workPreferences.initiative': '推进方式',
|
||||
'workPreferences.evidence': '证据要求',
|
||||
'workPreferences.concise': '只看要点',
|
||||
'workPreferences.balanced': '适中',
|
||||
'workPreferences.thorough': '充分说明',
|
||||
'workPreferences.ask': '先对齐再推进',
|
||||
'workPreferences.proactive': '主动推进可逆工作',
|
||||
'workPreferences.practical': '行动建议优先',
|
||||
'workPreferences.strict': '核实来源',
|
||||
'workPreferences.agents': '偏好的协作者(可选,最多 8 个)',
|
||||
'workPreferences.instructions': '还有什么习惯?(可选)',
|
||||
'workPreferences.example': '例如:先给结论,用表格比较方案,缺少的数据单独列出。',
|
||||
'workPreferences.preview': '你的交付风格',
|
||||
'workPreferences.reset': '重置滑杆',
|
||||
'workPreferences.saved': '已保存,将用于后续对话',
|
||||
'workPreferences.invalid': '请检查偏好选项,补充说明不要超过 800 字。',
|
||||
'workPreferences.busy': '请等待当前运行结束,再保存偏好。',
|
||||
'workbuddy.advanced': '高级 CLI 兼容设置',
|
||||
'workbuddy.configure': '需要配置 CLI',
|
||||
'workbuddy.providerAuth': '所选 WorkBuddy 模型的凭据或服务地址被拒绝,请在 WorkBuddy 中检查该模型的 API Key 和 URL 后重试。',
|
||||
|
||||
Reference in new issue
Block a user