@7n/tauri-components 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@7n/tauri-components",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Shared LLM agent engine + Vue/Quasar UI for Tauri apps (chat, journal, trust-tier approval).",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.js",
12
+ "./vue": "./src/vue/index.js",
13
+ "./components": "./src/components/index.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "!**/*.test.*",
18
+ "!**/*.spec.*",
19
+ "!**/__tests__/**",
20
+ "!**/fixtures/**"
21
+ ],
22
+ "dependencies": {
23
+ "marked": "^18.0.5"
24
+ },
25
+ "peerDependencies": {
26
+ "@tauri-apps/api": "^2.0.0",
27
+ "@tauri-apps/plugin-http": "^2.0.0",
28
+ "quasar": "^2.0.0",
29
+ "vue": "^3.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@tauri-apps/api": { "optional": true },
33
+ "@tauri-apps/plugin-http": { "optional": true },
34
+ "quasar": { "optional": true }
35
+ },
36
+ "knip": {
37
+ "ignoreDependencies": [
38
+ "marked"
39
+ ]
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.3",
43
+ "node": ">=24"
44
+ }
45
+ }
@@ -0,0 +1,170 @@
1
+ <template>
2
+ <BaseDialog
3
+ @update:model-value="val => emit('update:modelValue', val)"
4
+ @show="onShow"
5
+ :model-value="modelValue"
6
+ title="Agent (local LLM)"
7
+ icon="sym_o_smart_toy"
8
+ :width="560"
9
+ body-class="q-gutter-sm"
10
+ >
11
+ <template #header>
12
+ <q-btn @click="showConfig = !showConfig" icon="sym_o_tune" flat round dense size="sm" title="omlx config" />
13
+ </template>
14
+
15
+ <template v-if="showConfig">
16
+ <q-input v-model="baseUrl" dense outlined label="omlx base URL" />
17
+ <q-input v-model="model" dense outlined label="model" />
18
+ <q-input v-model="apiKey" dense outlined label="API key" type="password" />
19
+ <q-separator class="q-my-sm" />
20
+ </template>
21
+
22
+ <div v-if="turns.length" ref="logEl" class="chat-log">
23
+ <template v-for="(turn, i) in turns" :key="i">
24
+ <div v-if="turn.role === 'user'" class="chat-user">{{ turn.text }}</div>
25
+ <RequestView v-else :result="turn.result" />
26
+ </template>
27
+ <div v-if="running" class="chat-thinking">
28
+ <q-spinner-dots size="18px" /> думаю…
29
+ </div>
30
+ </div>
31
+
32
+ <q-input
33
+ v-model="prompt"
34
+ @keyup.ctrl.enter="send"
35
+ dense
36
+ outlined
37
+ autofocus
38
+ type="textarea"
39
+ autogrow
40
+ :label="inputLabel"
41
+ hint="наприклад: Create a task named deploy in /Users/.../mt, agent mode"
42
+ />
43
+
44
+ <!-- jscpd:ignore-start — formal footer markup; jscpd html-mode aligns its
45
+ attribute tokens with an unrelated import block (no shared logic) -->
46
+ <template #actions>
47
+ <DialogActions
48
+ @submit="send"
49
+ cancel-label="Закрити"
50
+ :submit-label="sendLabel"
51
+ icon="sym_o_play_arrow"
52
+ :disable="sendDisabled"
53
+ :loading="running"
54
+ />
55
+ </template>
56
+ <!-- jscpd:ignore-end -->
57
+ </BaseDialog>
58
+ </template>
59
+
60
+ <script setup>
61
+ import { computed, nextTick, ref } from 'vue'
62
+ import { useQuasar } from 'quasar'
63
+ import BaseDialog from './BaseDialog.vue'
64
+ import DialogActions from './DialogActions.vue'
65
+ import RequestView from './RequestView.vue'
66
+
67
+ // The agent gateway is injected by the host app (`useAgent({ catalog, … })` from
68
+ // @7n/tauri-components/vue), so this dialog stays domain-free and testable.
69
+ const props = defineProps({
70
+ modelValue: { type: Boolean, default: false },
71
+ agent: { type: Object, required: true },
72
+ })
73
+ const emit = defineEmits(['update:modelValue', 'ran'])
74
+
75
+ const $q = useQuasar()
76
+ const { baseUrl, model, apiKey, saveOmlx, loadOmlxEnv, request, respond } = props.agent
77
+
78
+ const prompt = ref('')
79
+ const running = ref(false)
80
+ const turns = ref([])
81
+ const requestId = ref(null)
82
+ const logEl = ref(null)
83
+ const showConfig = ref(false)
84
+
85
+ // Labels shift once a conversation is under way (fresh request → follow-up).
86
+ const inputLabel = computed(() => (turns.value.length ? 'Повідомлення' : 'Prompt'))
87
+ const sendLabel = computed(() => (turns.value.length ? 'Надіслати' : 'Запустити'))
88
+ const sendDisabled = computed(() => running.value || !prompt.value.trim())
89
+
90
+ /**
91
+ * Pull omlx config from the user's global settings; reset the conversation.
92
+ */
93
+ async function onShow() {
94
+ prompt.value = ''
95
+ turns.value = []
96
+ requestId.value = null
97
+ await loadOmlxEnv()
98
+ }
99
+
100
+ /**
101
+ * Scroll the transcript to the latest turn after the DOM settles.
102
+ */
103
+ async function scrollToEnd() {
104
+ await nextTick()
105
+ if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
106
+ }
107
+
108
+ /**
109
+ * Append an agent turn, latch the conversation id, refresh the graph on writes.
110
+ * @param {object} outcome structured result from request/respond
111
+ */
112
+ function apply(outcome) {
113
+ if (outcome.requestId) requestId.value = outcome.requestId
114
+ turns.value.push({ role: 'agent', result: outcome })
115
+ if (outcome.actions?.some(action => action.envelope?.ok)) emit('ran')
116
+ scrollToEnd()
117
+ }
118
+
119
+ /**
120
+ * Send the current message: start a new request, or resume the conversation.
121
+ */
122
+ async function send() {
123
+ const text = prompt.value.trim()
124
+ if (!text || running.value) return
125
+ prompt.value = ''
126
+ turns.value.push({ role: 'user', text })
127
+ scrollToEnd()
128
+ running.value = true
129
+ try {
130
+ saveOmlx()
131
+ apply(await (requestId.value ? respond(requestId.value, text) : request(text)))
132
+ } catch (error) {
133
+ $q.notify({ type: 'negative', message: String(error?.message ?? error) })
134
+ }
135
+ finally {
136
+ running.value = false
137
+ }
138
+ }
139
+ </script>
140
+
141
+ <style scoped>
142
+ .chat-log {
143
+ display: flex;
144
+ flex-direction: column;
145
+ gap: 10px;
146
+ max-height: 46vh;
147
+ overflow-y: auto;
148
+ padding: 2px;
149
+ }
150
+
151
+ .chat-user {
152
+ align-self: flex-end;
153
+ max-width: 85%;
154
+ padding: 7px 11px;
155
+ border-radius: 12px 12px 2px 12px;
156
+ background: color-mix(in srgb, #0a84ff 18%, transparent);
157
+ white-space: pre-wrap;
158
+ overflow-wrap: anywhere;
159
+ font-size: 13px;
160
+ line-height: 1.45;
161
+ }
162
+
163
+ .chat-thinking {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 6px;
167
+ font-size: 12px;
168
+ opacity: 0.7;
169
+ }
170
+ </style>
@@ -0,0 +1,259 @@
1
+ <template>
2
+ <BaseDialog
3
+ @update:model-value="val => emit('update:modelValue', val)"
4
+ @show="refresh"
5
+ :model-value="modelValue"
6
+ title="Request journal"
7
+ icon="sym_o_history"
8
+ :width="680"
9
+ body-class=""
10
+ >
11
+ <template #header>
12
+ <q-btn @click="refresh" icon="sym_o_refresh" flat round dense size="sm" :loading="loading" title="Refresh" />
13
+ </template>
14
+
15
+ <div v-if="!records.length && !loading" class="audit-empty">No requests yet</div>
16
+
17
+ <div v-for="rec in records" :key="rec.id" class="audit-row">
18
+ <div @click="toggle(rec.id)" class="audit-head">
19
+ <span class="state-pill" :style="{ '--c': statusColor(rec.status) }">
20
+ <span class="state-pill__dot" />
21
+ {{ rec.status }}
22
+ </span>
23
+ <span class="audit-actor">{{ rec.actor?.kind }}{{ rec.actor?.id ? `:${rec.actor.id}` : '' }}</span>
24
+ <span class="audit-intent">{{ rec.intent }}</span>
25
+ <q-space />
26
+ <span class="audit-time">{{ fmtTime(rec.createdAt) }}</span>
27
+ </div>
28
+
29
+ <div v-if="expandedId === rec.id" class="audit-body">
30
+ <RequestView @respond="msg => onRespond(rec, msg)" :result="rec" :busy="busyId === rec.id" />
31
+ <div v-if="rec.status === 'needs_approval' && rec.pendingApproval" class="audit-approval">
32
+ <div class="audit-pending">
33
+ Approve action: <code>{{ rec.pendingApproval.tool }}({{ JSON.stringify(rec.pendingApproval.input) }})</code>
34
+ </div>
35
+ <div class="row q-gutter-sm">
36
+ <q-btn
37
+ @click="onApprove(rec, true)"
38
+ label="Підтвердити"
39
+ color="negative"
40
+ unelevated
41
+ no-caps
42
+ :loading="busyId === rec.id"
43
+ />
44
+ <q-btn @click="onApprove(rec, false)" label="Відхилити" flat no-caps :disable="busyId === rec.id" />
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </BaseDialog>
50
+ </template>
51
+
52
+ <script setup>
53
+ import { ref } from 'vue'
54
+ import { useQuasar } from 'quasar'
55
+ import BaseDialog from './BaseDialog.vue'
56
+ import RequestView from './RequestView.vue'
57
+
58
+ const STATUS_COLOR = {
59
+ pending: '#8e8e93',
60
+ running: '#0a84ff',
61
+ done: '#30d158',
62
+ partial: '#ff9f0a',
63
+ needs_clarification: '#64d2ff',
64
+ needs_approval: '#ff9f0a',
65
+ failed: '#ff453a',
66
+ rejected: '#8e8e93',
67
+ }
68
+
69
+ // The agent gateway (journal + respond + approve) is injected by the host app,
70
+ // keeping this dialog domain-free.
71
+ const props = defineProps({
72
+ modelValue: { type: Boolean, default: false },
73
+ agent: { type: Object, required: true },
74
+ })
75
+ const emit = defineEmits(['update:modelValue', 'changed'])
76
+
77
+ const $q = useQuasar()
78
+ const { journal, respond, approve } = props.agent
79
+
80
+ const records = ref([])
81
+ const loading = ref(false)
82
+ const expandedId = ref(null)
83
+ const busyId = ref(null)
84
+
85
+ /**
86
+ * @param {string} status request status
87
+ * @returns {string} accent color
88
+ */
89
+ function statusColor(status) {
90
+ return STATUS_COLOR[status] ?? '#8e8e93'
91
+ }
92
+
93
+ /**
94
+ * @param {number} millis epoch millis
95
+ * @returns {string} locale time
96
+ */
97
+ function fmtTime(millis) {
98
+ return millis ? new Date(millis).toLocaleString() : ''
99
+ }
100
+
101
+ /**
102
+ * Reload the journal list.
103
+ */
104
+ async function refresh() {
105
+ loading.value = true
106
+ try {
107
+ records.value = await journal.list()
108
+ }
109
+ catch (error) {
110
+ $q.notify({ type: 'negative', message: String(error?.message ?? error) })
111
+ }
112
+ finally {
113
+ loading.value = false
114
+ }
115
+ }
116
+
117
+ /**
118
+ * @param {string} id record id to expand/collapse
119
+ */
120
+ function toggle(id) {
121
+ expandedId.value = expandedId.value === id ? null : id
122
+ }
123
+
124
+ /**
125
+ * Answer a pending clarification on a journaled request, then refresh.
126
+ * @param {object} rec the record
127
+ * @param {string} message the human's answer
128
+ */
129
+ async function onRespond(rec, message) {
130
+ busyId.value = rec.id
131
+ try {
132
+ await respond(rec.id, message)
133
+ await refresh()
134
+ emit('changed')
135
+ }
136
+ catch (error) {
137
+ $q.notify({ type: 'negative', message: String(error?.message ?? error) })
138
+ }
139
+ finally {
140
+ busyId.value = null
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Approve or reject a pending destructive action, then refresh.
146
+ * @param {object} rec the record
147
+ * @param {boolean} ok true to approve (execute), false to reject
148
+ */
149
+ async function onApprove(rec, ok) {
150
+ busyId.value = rec.id
151
+ try {
152
+ await approve(rec.id, ok)
153
+ await refresh()
154
+ emit('changed')
155
+ }
156
+ catch (error) {
157
+ $q.notify({ type: 'negative', message: String(error?.message ?? error) })
158
+ }
159
+ finally {
160
+ busyId.value = null
161
+ }
162
+ }
163
+ </script>
164
+
165
+ <style scoped>
166
+ .audit-empty {
167
+ text-align: center;
168
+ padding: 40px 0;
169
+ font-size: 13px;
170
+ opacity: 0.4;
171
+ }
172
+
173
+ .audit-approval {
174
+ display: flex;
175
+ flex-direction: column;
176
+ gap: 8px;
177
+ margin-top: 8px;
178
+ padding: 10px;
179
+ border-radius: 8px;
180
+ background: color-mix(in srgb, #ff9f0a 12%, transparent);
181
+ }
182
+
183
+ .audit-pending {
184
+ font-size: 13px;
185
+ overflow-wrap: anywhere;
186
+ }
187
+
188
+ .audit-row {
189
+ border-bottom: 1px solid rgb(255 255 255 / 7%);
190
+ }
191
+
192
+ .body--light .audit-row {
193
+ border-bottom-color: rgb(0 0 0 / 7%);
194
+ }
195
+
196
+ .audit-head {
197
+ display: flex;
198
+ align-items: center;
199
+ gap: 8px;
200
+ padding: 8px 4px;
201
+ cursor: pointer;
202
+ }
203
+
204
+ .audit-head:hover {
205
+ background: rgb(255 255 255 / 5%);
206
+ }
207
+
208
+ .body--light .audit-head:hover {
209
+ background: rgb(0 0 0 / 4%);
210
+ }
211
+
212
+ .audit-actor {
213
+ font-family: 'SF Mono', ui-monospace, 'JetBrains Mono', monospace;
214
+ font-size: 11px;
215
+ opacity: 0.6;
216
+ flex: 0 0 auto;
217
+ }
218
+
219
+ .audit-intent {
220
+ font-size: 13px;
221
+ overflow: hidden;
222
+ text-overflow: ellipsis;
223
+ white-space: nowrap;
224
+ flex: 1 1 auto;
225
+ min-width: 0;
226
+ }
227
+
228
+ .audit-time {
229
+ font-size: 11px;
230
+ opacity: 0.4;
231
+ flex: 0 0 auto;
232
+ }
233
+
234
+ .audit-body {
235
+ padding: 6px 4px 12px;
236
+ }
237
+
238
+ .state-pill {
239
+ display: inline-flex;
240
+ align-items: center;
241
+ gap: 5px;
242
+ height: 20px;
243
+ padding: 0 8px;
244
+ border-radius: 6px;
245
+ font-family: 'SF Mono', ui-monospace, 'JetBrains Mono', monospace;
246
+ font-size: 11px;
247
+ color: var(--c);
248
+ background: color-mix(in srgb, var(--c) 14%, transparent);
249
+ border: 1px solid color-mix(in srgb, var(--c) 28%, transparent);
250
+ flex: 0 0 auto;
251
+ }
252
+
253
+ .state-pill__dot {
254
+ width: 6px;
255
+ height: 6px;
256
+ border-radius: 50%;
257
+ background: var(--c);
258
+ }
259
+ </style>
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <q-dialog
3
+ @update:model-value="value => emit('update:modelValue', value)"
4
+ v-bind="$attrs"
5
+ :model-value="modelValue"
6
+ >
7
+ <q-card class="base-dialog" :style="{ width: `${width}px` }">
8
+ <q-card-section class="row items-center no-wrap q-pb-sm">
9
+ <q-icon v-if="icon" :name="icon" size="20px" class="q-mr-sm" />
10
+ <span class="base-dialog__title">{{ title }}</span>
11
+ <q-space />
12
+ <slot name="header" />
13
+ <q-btn v-close-popup icon="sym_o_close" flat round dense size="sm" />
14
+ </q-card-section>
15
+
16
+ <q-separator />
17
+
18
+ <q-card-section :class="bodyClass">
19
+ <slot />
20
+ </q-card-section>
21
+
22
+ <q-separator />
23
+
24
+ <q-card-actions align="right" class="q-pa-md">
25
+ <slot name="actions" />
26
+ </q-card-actions>
27
+ </q-card>
28
+ </q-dialog>
29
+ </template>
30
+
31
+ <script setup>
32
+ // Shared shell for app dialogs (header icon+title+close, body slot, actions
33
+ // slot). Keeps the Quasar dialog boilerplate in one place so feature dialogs
34
+ // (create task, agent, …) carry only their own content. `$attrs` forwards
35
+ // listeners like @show to the underlying q-dialog.
36
+ defineOptions({ inheritAttrs: false })
37
+
38
+ defineProps({
39
+ modelValue: { type: Boolean, default: false },
40
+ title: { type: String, required: true },
41
+ icon: { type: String, default: '' },
42
+ width: { type: Number, default: 520 },
43
+ bodyClass: { type: String, default: 'q-gutter-md' },
44
+ })
45
+ const emit = defineEmits(['update:modelValue'])
46
+ </script>
47
+
48
+ <style scoped>
49
+ .base-dialog {
50
+ max-width: 92vw;
51
+ }
52
+
53
+ .base-dialog__title {
54
+ font-family: 'SF Mono', ui-monospace, 'JetBrains Mono', monospace;
55
+ font-size: 15px;
56
+ font-weight: 600;
57
+ }
58
+ </style>
@@ -0,0 +1,26 @@
1
+ <template>
2
+ <q-btn v-close-popup :label="cancelLabel" flat no-caps />
3
+ <q-btn
4
+ @click="emit('submit')"
5
+ :label="submitLabel"
6
+ :icon="icon"
7
+ :disable="disable"
8
+ :loading="loading"
9
+ color="primary"
10
+ unelevated
11
+ no-caps
12
+ />
13
+ </template>
14
+
15
+ <script setup>
16
+ // Shared dialog footer: a close button plus a primary action. Keeps the
17
+ // cancel + submit pattern canonical across feature dialogs (see BaseDialog).
18
+ defineProps({
19
+ cancelLabel: { type: String, default: 'Скасувати' },
20
+ submitLabel: { type: String, required: true },
21
+ icon: { type: String, default: undefined },
22
+ disable: { type: Boolean, default: false },
23
+ loading: { type: Boolean, default: false },
24
+ })
25
+ const emit = defineEmits(['submit'])
26
+ </script>
@@ -0,0 +1,101 @@
1
+ <template>
2
+ <div class="req-view">
3
+ <span class="state-pill" :style="{ '--c': statusColor }">
4
+ <span class="state-pill__dot" />
5
+ {{ result.status }}
6
+ </span>
7
+
8
+ <div v-if="result.summary" class="req-summary">{{ result.summary }}</div>
9
+ <div v-if="result.question" class="req-summary">{{ result.question }}</div>
10
+ <div v-if="result.error" class="req-error">{{ result.error }}</div>
11
+
12
+ <div v-if="result.actions?.length" class="req-actions">
13
+ <div v-for="(action, i) in result.actions" :key="i" class="req-action">
14
+ <q-icon
15
+ :name="action.envelope?.ok ? 'sym_o_check_circle' : 'sym_o_error'"
16
+ :color="action.envelope?.ok ? 'positive' : 'negative'"
17
+ size="14px"
18
+ />
19
+ <code>{{ action.tool }}({{ JSON.stringify(action.input) }})</code>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup>
26
+ import { computed } from 'vue'
27
+
28
+ const STATUS_COLOR = {
29
+ pending: '#8e8e93',
30
+ running: '#0a84ff',
31
+ done: '#30d158',
32
+ partial: '#ff9f0a',
33
+ needs_clarification: '#64d2ff',
34
+ needs_approval: '#ff9f0a',
35
+ failed: '#ff453a',
36
+ rejected: '#8e8e93',
37
+ }
38
+
39
+ const props = defineProps({
40
+ result: { type: Object, required: true },
41
+ })
42
+
43
+ const statusColor = computed(() => STATUS_COLOR[props.result.status] ?? '#8e8e93')
44
+ </script>
45
+
46
+ <style scoped>
47
+ .req-view {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 8px;
51
+ }
52
+
53
+ .req-summary {
54
+ line-height: 1.5;
55
+ white-space: pre-wrap;
56
+ font-size: 13px;
57
+ }
58
+
59
+ .req-error {
60
+ color: #ff6b60;
61
+ font-size: 13px;
62
+ }
63
+
64
+ .req-actions {
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 3px;
68
+ }
69
+
70
+ .req-action {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 6px;
74
+ font-family: 'SF Mono', ui-monospace, 'JetBrains Mono', monospace;
75
+ font-size: 11px;
76
+ opacity: 0.8;
77
+ overflow-wrap: anywhere;
78
+ }
79
+
80
+ .state-pill {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 5px;
84
+ align-self: flex-start;
85
+ height: 20px;
86
+ padding: 0 8px;
87
+ border-radius: 6px;
88
+ font-family: 'SF Mono', ui-monospace, 'JetBrains Mono', monospace;
89
+ font-size: 11px;
90
+ color: var(--c);
91
+ background: color-mix(in srgb, var(--c) 14%, transparent);
92
+ border: 1px solid color-mix(in srgb, var(--c) 28%, transparent);
93
+ }
94
+
95
+ .state-pill__dot {
96
+ width: 6px;
97
+ height: 6px;
98
+ border-radius: 50%;
99
+ background: var(--c);
100
+ }
101
+ </style>
@@ -0,0 +1,10 @@
1
+ // `@7n/tauri-components/components` — Quasar dialogs for the agent. AgentDialog
2
+ // and AuditDialog take the injected `agent` gateway (from ./vue useAgent) via a
3
+ // prop; the rest are pure props-only building blocks. Requires the host app to
4
+ // provide vue + quasar (peer deps) and to have Quasar installed.
5
+
6
+ export { default as AgentDialog } from './AgentDialog.vue'
7
+ export { default as AuditDialog } from './AuditDialog.vue'
8
+ export { default as BaseDialog } from './BaseDialog.vue'
9
+ export { default as DialogActions } from './DialogActions.vue'
10
+ export { default as RequestView } from './RequestView.vue'
@@ -0,0 +1,171 @@
1
+ import { runAgent } from './llm.js'
2
+
3
+ // Agent-gateway handlers — start / resume / approve the agent loop on behalf of a
4
+ // caller (human via UI or agent via MCP). They own the journal lifecycle and
5
+ // return a structured result contract.
6
+ //
7
+ // All collaborators are injected (no module-level singletons): `dispatch`,
8
+ // `journal`, the scoped `tools` manifest, the `gate` decision fn, `buildSystem`
9
+ // (turns optional grounding context into a system prompt) and `grounding` (an
10
+ // optional read tool whose output grounds the prompt). createAgentKit wires
11
+ // these from an app catalog; tests pass fakes.
12
+ //
13
+ // Trust: destructive tool calls from an agent pause as `needs_approval` (the
14
+ // loop never executes them); a human approves later via handleApprove.
15
+
16
+ const QUESTION_RE = /\?\s*$/
17
+
18
+ /**
19
+ * @param {string} text candidate text
20
+ * @returns {boolean} true when text ends with a question mark
21
+ */
22
+ function isQuestion(text) {
23
+ return typeof text === 'string' && QUESTION_RE.test(text.trim())
24
+ }
25
+
26
+ /**
27
+ * Derive the structured result fields from a runAgent result.
28
+ * @param {object} result runAgent result
29
+ * @returns {{ status: string, summary: string|null, question: string|null, pendingApproval: object|null }} fields
30
+ */
31
+ function finalize(result) {
32
+ const question = isQuestion(result.content) ? result.content : null
33
+ let status = 'done'
34
+ if (result.stopped === 'needs_approval') status = 'needs_approval'
35
+ else if (result.stopped === 'max_steps') status = 'partial'
36
+ else if (question) status = 'needs_clarification'
37
+ return {
38
+ status,
39
+ summary: question ? null : (result.content || null),
40
+ question,
41
+ pendingApproval: result.pendingApproval ?? null,
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Run the loop (fresh or resumed), persist to the journal, return the envelope.
47
+ * @param {{ requestId: string, runArgs: object, baseActions: object[], journal: object }} ctx run context
48
+ * @returns {Promise<object>} structured result envelope
49
+ */
50
+ async function runAndJournal({ requestId, runArgs, baseActions, journal }) {
51
+ let result
52
+ try {
53
+ result = await runAgent(runArgs)
54
+ }
55
+ catch (error) {
56
+ await journal.update(requestId, { status: 'failed', error: String(error?.message ?? error) })
57
+ return { requestId, status: 'failed', summary: null, actions: baseActions, question: null, pendingApproval: null }
58
+ }
59
+
60
+ const fields = finalize(result)
61
+ const actions = [...baseActions, ...result.trace]
62
+ await journal.update(requestId, { ...fields, messages: result.messages, actions })
63
+ return { requestId, ...fields, actions }
64
+ }
65
+
66
+ /**
67
+ * Fetch optional grounding context via a read tool; {} when not configured / on failure.
68
+ * @param {Function} dispatch tool dispatcher
69
+ * @param {{ tool: string, key?: string, fallback?: unknown }} [grounding] grounding config
70
+ * @returns {Promise<object>} context object keyed by grounding.key (default "grounding")
71
+ */
72
+ async function groundingContext(dispatch, grounding) {
73
+ if (!grounding?.tool) return {}
74
+ const key = grounding.key ?? 'grounding'
75
+ const fallback = grounding.fallback ?? []
76
+ try {
77
+ const res = await dispatch(grounding.tool, {})
78
+ return { [key]: res?.ok ? res.output : fallback }
79
+ }
80
+ catch {
81
+ return { [key]: fallback }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Start a new agent request.
87
+ * @param {{ intent: string, actor: object, chat: Function, dispatch: Function, journal: object, tools: object[], gate: Function, buildSystem?: Function, grounding?: object }} opts request parameters
88
+ * @returns {Promise<object>} structured result envelope
89
+ */
90
+ export async function handleRequest({ intent, actor, chat, dispatch, journal, tools, gate, buildSystem, grounding }) {
91
+ const id = await journal.create({ intent, actor })
92
+ await journal.update(id, { status: 'running' })
93
+ const ctx = await groundingContext(dispatch, grounding)
94
+ const system = buildSystem ? buildSystem(ctx) : undefined
95
+ return runAndJournal({
96
+ requestId: id,
97
+ baseActions: [],
98
+ journal,
99
+ runArgs: { system, prompt: intent, chat, dispatch, tools, gate },
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Resume a conversation with a follow-up / clarification answer.
105
+ * @param {{ requestId: string, message: string, actor: object, chat: Function, dispatch: Function, journal: object, tools: object[], gate: Function }} opts resume parameters
106
+ * @returns {Promise<object>} updated result envelope
107
+ */
108
+ export async function handleRespond({ requestId, message, chat, dispatch, journal, tools, gate }) {
109
+ let record
110
+ try {
111
+ record = await journal.load(requestId)
112
+ }
113
+ catch {
114
+ return { requestId, status: 'failed', summary: null, actions: [], question: 'Request not found.', pendingApproval: null }
115
+ }
116
+
117
+ if (record.status === 'running' || !Array.isArray(record.messages) || record.messages.length === 0) {
118
+ return { requestId, status: record.status, summary: record.summary, actions: record.actions, question: null, pendingApproval: record.pendingApproval ?? null }
119
+ }
120
+
121
+ await journal.update(requestId, { status: 'running' })
122
+ return runAndJournal({
123
+ requestId,
124
+ baseActions: record.actions ?? [],
125
+ journal,
126
+ runArgs: { messages: [...record.messages, { role: 'user', content: message }], chat, dispatch, tools, gate },
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Approve (or reject) a pending destructive action. Executes with the approver's
132
+ * (human) authority via the injected dispatch — no gate.
133
+ * @param {{ requestId: string, approve: boolean, dispatch: Function, journal: object }} opts approval parameters
134
+ * @returns {Promise<object>} updated result envelope
135
+ */
136
+ export async function handleApprove({ requestId, approve, dispatch, journal }) {
137
+ let record
138
+ try {
139
+ record = await journal.load(requestId)
140
+ }
141
+ catch {
142
+ return { requestId, status: 'failed', summary: null, actions: [], question: 'Request not found.', pendingApproval: null }
143
+ }
144
+
145
+ if (record.status !== 'needs_approval' || !record.pendingApproval) {
146
+ return { requestId, status: record.status, summary: record.summary, actions: record.actions ?? [], question: null, pendingApproval: null }
147
+ }
148
+
149
+ if (!approve) {
150
+ const fields = { status: 'rejected', summary: 'Rejected by human.', pendingApproval: null }
151
+ await journal.update(requestId, fields)
152
+ return { requestId, ...fields, actions: record.actions ?? [], question: null }
153
+ }
154
+
155
+ const { tool, input } = record.pendingApproval
156
+ await journal.update(requestId, { status: 'running' })
157
+ const envelope = await dispatch(tool, input)
158
+ const actions = [...(record.actions ?? []), { tool, input, envelope }]
159
+
160
+ if (!envelope.ok) {
161
+ // Execution failed (e.g. transient) — stay needs_approval so the human can
162
+ // retry after fixing; keep pendingApproval, record the error and the attempt.
163
+ const error = envelope.error?.message ?? 'execution failed'
164
+ await journal.update(requestId, { status: 'needs_approval', actions, error })
165
+ return { requestId, status: 'needs_approval', summary: null, actions, error, question: null, pendingApproval: record.pendingApproval }
166
+ }
167
+
168
+ const summary = `Approved: ${tool} executed.`
169
+ await journal.update(requestId, { status: 'done', actions, summary, error: null, pendingApproval: null })
170
+ return { requestId, status: 'done', summary, actions, question: null, pendingApproval: null }
171
+ }
@@ -0,0 +1,43 @@
1
+ import { handleApprove, handleRequest, handleRespond } from './agent-handler.js'
2
+ import { createDispatch } from './dispatch.js'
3
+ import { toolManifest } from './manifest.js'
4
+ import { classify, DEFAULT_ACTOR_TIERS, scopedManifest } from './scope.js'
5
+
6
+ // createAgentKit binds the generic agent machinery to ONE app's tool catalog.
7
+ // It is the single integration seam: an app supplies its catalog, a domain
8
+ // system prompt, a transport (Tauri invoke / CLI spawn / fetch), a journal store
9
+ // and optional grounding, and gets back request/respond/approve plus the derived
10
+ // manifest + scope helpers — all pre-bound, so callers never re-thread the
11
+ // catalog. The `chat` fn stays per-call (its config can change between requests).
12
+
13
+ /**
14
+ * @param {object} config kit configuration
15
+ * @param {object[]} config.catalog tool definitions (required)
16
+ * @param {string|((ctx: object) => string)} [config.systemPrompt] domain system prompt (or builder from grounding ctx)
17
+ * @param {(tool: object, input: object) => unknown} [config.transport] backend runner; omit to build the kit without dispatch (manifest/scope only)
18
+ * @param {object} [config.journal] journal store { create, load, update, list }
19
+ * @param {Record<string, number>} [config.actorTiers] max executable tier rank per actor kind
20
+ * @param {{ tool: string, key?: string, fallback?: unknown }} [config.grounding] optional read tool to ground the prompt
21
+ * @returns {{ dispatch: Function|null, classify: Function, scopedManifest: Function, toolManifest: Function, request: Function, respond: Function, approve: Function }} bound kit
22
+ */
23
+ export function createAgentKit({ catalog, systemPrompt = '', transport, journal, actorTiers = DEFAULT_ACTOR_TIERS, grounding } = {}) {
24
+ if (!Array.isArray(catalog)) throw new Error('createAgentKit: catalog (array) is required')
25
+
26
+ const dispatch = transport ? createDispatch(catalog, transport) : null
27
+ const buildSystem = typeof systemPrompt === 'function' ? systemPrompt : () => systemPrompt
28
+ const toolsFor = actor => scopedManifest(catalog, actorTiers, actor)
29
+ const gateFor = actor => name => classify(catalog, actorTiers, actor, name)
30
+
31
+ return {
32
+ dispatch,
33
+ classify: (actor, name) => classify(catalog, actorTiers, actor, name),
34
+ scopedManifest: actor => toolsFor(actor),
35
+ toolManifest: allow => toolManifest(catalog, allow),
36
+ request: ({ intent, actor, chat }) =>
37
+ handleRequest({ intent, actor, chat, dispatch, journal, tools: toolsFor(actor), gate: gateFor(actor), buildSystem, grounding }),
38
+ respond: ({ requestId, message, actor, chat }) =>
39
+ handleRespond({ requestId, message, actor, chat, dispatch, journal, tools: toolsFor(actor), gate: gateFor(actor) }),
40
+ approve: ({ requestId, approve }) =>
41
+ handleApprove({ requestId, approve, dispatch, journal }),
42
+ }
43
+ }
@@ -0,0 +1,50 @@
1
+ import { getTool } from './tools.js'
2
+
3
+ // dispatch(name, input): validate input against the tool schema, run it via the
4
+ // injected transport, return the uniform envelope. The transport is the only
5
+ // thing that differs per consumer (Tauri invoke in-app, a CLI spawn in an
6
+ // orchestrator), so the contract stays identical everywhere.
7
+
8
+ /**
9
+ * Validate an input object against a tool's schema and custom validator.
10
+ * @param {object} tool tool definition
11
+ * @param {object} [input] candidate input
12
+ * @returns {string|null} error message, or null when valid
13
+ */
14
+ export function validateInput(tool, input) {
15
+ const data = input ?? {}
16
+ for (const [key, spec] of Object.entries(tool.input)) {
17
+ const value = data[key]
18
+ if (value === undefined || value === null) {
19
+ if (spec.required) return `Missing required field: ${key}`
20
+ continue
21
+ }
22
+ if (spec.type === 'string' && typeof value !== 'string') return `Field "${key}" must be a string`
23
+ if (spec.type === 'object' && (typeof value !== 'object' || Array.isArray(value))) return `Field "${key}" must be an object`
24
+ }
25
+ return tool.validate ? tool.validate(data) : null
26
+ }
27
+
28
+ /**
29
+ * Build a dispatch function bound to a catalog and a transport.
30
+ * @param {object[]} catalog tool definitions
31
+ * @param {(tool: object, input: object) => unknown} transport runs the tool's backend call
32
+ * @returns {(name: string, input?: object) => Promise<object>} dispatch returning an envelope
33
+ */
34
+ export function createDispatch(catalog, transport) {
35
+ return async function dispatch(name, input) {
36
+ const tool = getTool(catalog, name)
37
+ if (!tool) return { ok: false, error: { code: 'not_found', message: `Unknown tool: ${name}` } }
38
+
39
+ const invalid = validateInput(tool, input)
40
+ if (invalid) return { ok: false, error: { code: 'validation', message: invalid } }
41
+
42
+ try {
43
+ const output = await transport(tool, input ?? {})
44
+ return { ok: true, output }
45
+ }
46
+ catch (error) {
47
+ return { ok: false, error: { code: 'io', message: String(error?.message ?? error) } }
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,90 @@
1
+ // The tool-calling loop and an OpenAI-compatible chat adapter. Both are
2
+ // stateless and domain-agnostic: the system prompt and tool manifest are passed
3
+ // in by the caller (an app builds them from its catalog — see agent-kit.js).
4
+
5
+ const DEFAULT_SYSTEM = 'You are a tool-using assistant. Call one tool at a time and wait for its result. If the request is ambiguous, ask a clarifying question with no tool call. When done, reply with a plain-text summary and no tool call.'
6
+
7
+ /**
8
+ * Run the tool-calling loop until the model answers without a tool call.
9
+ * Accepts either a fresh prompt or an existing messages[] for sessional resume.
10
+ * @param {object} params loop parameters
11
+ * @param {string} [params.prompt] user request (fresh start)
12
+ * @param {object[]} [params.messages] existing conversation to resume (takes priority over prompt)
13
+ * @param {(name: string, input: object) => Promise<object>} params.dispatch tool dispatcher returning an envelope
14
+ * @param {(req: {messages: object[], tools: object[]}) => Promise<object>} params.chat model call returning an assistant message
15
+ * @param {number} [params.maxSteps] safety cap on loop iterations
16
+ * @param {string} [params.system] system prompt (only used when building fresh from prompt)
17
+ * @param {object[]} [params.tools] LLM tool manifest (pass a scoped manifest to restrict)
18
+ * @param {(name: string) => 'allow'|'approval'|'deny'} [params.gate] per-call decision (default: allow)
19
+ * @returns {Promise<{content: string, steps: number, trace: object[], messages: object[], stopped?: string, pendingApproval?: object}>} loop result
20
+ */
21
+ export async function runAgent({ prompt, messages: initialMessages, dispatch, chat, maxSteps = 6, system = DEFAULT_SYSTEM, tools = [], gate = () => 'allow' }) {
22
+ const messages = initialMessages
23
+ ? [...initialMessages]
24
+ : [
25
+ { role: 'system', content: system },
26
+ { role: 'user', content: prompt },
27
+ ]
28
+ const trace = []
29
+
30
+ for (let step = 0; step < maxSteps; step++) {
31
+ const reply = await chat({ messages, tools })
32
+ messages.push(reply)
33
+
34
+ const calls = reply.tool_calls ?? []
35
+ if (calls.length === 0) {
36
+ return { content: reply.content ?? '', steps: step + 1, trace, messages }
37
+ }
38
+
39
+ for (const call of calls) {
40
+ let input = {}
41
+ try {
42
+ input = call.function.arguments ? JSON.parse(call.function.arguments) : {}
43
+ }
44
+ catch {
45
+ // leave input empty — dispatch's schema validation reports the problem
46
+ }
47
+
48
+ const decision = gate(call.function.name)
49
+ if (decision === 'approval') {
50
+ // Destructive request from a non-human: pause for human approval.
51
+ return { content: reply.content ?? '', steps: step + 1, trace, messages, stopped: 'needs_approval', pendingApproval: { tool: call.function.name, input } }
52
+ }
53
+
54
+ const envelope = decision === 'deny'
55
+ ? { ok: false, error: { code: 'forbidden', message: `Tool "${call.function.name}" is not allowed.` } }
56
+ : await dispatch(call.function.name, input)
57
+ trace.push({ tool: call.function.name, input, envelope })
58
+ messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(envelope) })
59
+ }
60
+ }
61
+
62
+ return { content: '', steps: maxSteps, trace, messages, stopped: 'max_steps' }
63
+ }
64
+
65
+ /**
66
+ * Build a `chat` function that calls an OpenAI-compatible endpoint (omlx).
67
+ * @param {object} params config
68
+ * @param {string} params.baseUrl base URL incl. /v1 (e.g. http://127.0.0.1:10240/v1)
69
+ * @param {string} params.model served model id
70
+ * @param {string} [params.apiKey] optional bearer token
71
+ * @param {typeof fetch} [params.fetchFn] fetch implementation (injectable for tests / tauri-http)
72
+ * @returns {(req: {messages: object[], tools: object[]}) => Promise<object>} chat function
73
+ */
74
+ export function createOpenAiChat({ baseUrl, model, apiKey, fetchFn = fetch }) {
75
+ return async function chat({ messages, tools }) {
76
+ const response = await fetchFn(`${baseUrl}/chat/completions`, {
77
+ method: 'POST',
78
+ headers: {
79
+ 'content-type': 'application/json',
80
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
81
+ },
82
+ body: JSON.stringify({ model, messages, tools, tool_choice: 'auto' }),
83
+ })
84
+ if (!response.ok) {
85
+ throw new Error(`omlx ${response.status}: ${await response.text()}`)
86
+ }
87
+ const data = await response.json()
88
+ return data.choices[0].message
89
+ }
90
+ }
@@ -0,0 +1,44 @@
1
+ // Consumer-facing artifacts derived from a tool catalog. The LLM adapter (omlx —
2
+ // OpenAI-compatible MLX server) consumes the OpenAI function-calling shape. The
3
+ // catalog is passed in (see tools.js) so these stay app-agnostic.
4
+
5
+ /**
6
+ * Convert a tool input spec into a JSON Schema object.
7
+ * @param {Record<string, {type: string, required?: boolean, description?: string}>} input tool input spec
8
+ * @returns {object} JSON Schema for the parameters object
9
+ */
10
+ export function toJsonSchema(input) {
11
+ const properties = {}
12
+ const required = []
13
+ for (const [key, spec] of Object.entries(input)) {
14
+ properties[key] = spec.description ? { type: spec.type, description: spec.description } : { type: spec.type }
15
+ if (spec.required) required.push(key)
16
+ }
17
+ return required.length ? { type: 'object', properties, required } : { type: 'object', properties }
18
+ }
19
+
20
+ /**
21
+ * OpenAI function-calling tool definitions, optionally filtered (e.g. by scope).
22
+ * @param {object[]} catalog tool definitions
23
+ * @param {(tool: object) => boolean} [allow] predicate; default includes all tools
24
+ * @returns {object[]} OpenAI `tools` array
25
+ */
26
+ export function toolManifest(catalog, allow = () => true) {
27
+ return catalog.filter(tool => allow(tool)).map(tool => ({
28
+ type: 'function',
29
+ function: {
30
+ name: tool.name,
31
+ description: tool.summary,
32
+ parameters: toJsonSchema(tool.input),
33
+ },
34
+ }))
35
+ }
36
+
37
+ /**
38
+ * Compact catalog listing (name + summary).
39
+ * @param {object[]} catalog tool definitions
40
+ * @returns {{name: string, summary: string}[]} tool list
41
+ */
42
+ export function listTools(catalog) {
43
+ return catalog.map(({ name, summary }) => ({ name, summary }))
44
+ }
@@ -0,0 +1,52 @@
1
+ import { toolManifest } from './manifest.js'
2
+ import { getTool } from './tools.js'
3
+
4
+ // Trust scope. Each tool has a tier; each actor kind has a max tier it may
5
+ // EXECUTE directly. Above that:
6
+ // - agent + destructive → 'approval' (request goes to the human via the journal);
7
+ // - otherwise → 'deny'.
8
+ // Humans (UI, at the keyboard) execute everything directly.
9
+
10
+ const TIER_RANK = { read: 0, write: 1, destructive: 2 }
11
+ export const DEFAULT_ACTOR_TIERS = { human: 2, agent: 1 }
12
+
13
+ /**
14
+ * Classify a tool call for an actor.
15
+ * @param {object[]} catalog tool definitions
16
+ * @param {Record<string, number>|undefined} actorTiers max executable tier rank per actor kind
17
+ * @param {{ kind?: string }} actor caller identity
18
+ * @param {string} toolName tool name
19
+ * @returns {'allow'|'approval'|'deny'} decision
20
+ */
21
+ export function classify(catalog, actorTiers, actor, toolName) {
22
+ const tiers = actorTiers ?? DEFAULT_ACTOR_TIERS
23
+ const tool = getTool(catalog, toolName)
24
+ if (!tool) return 'deny'
25
+ const rank = TIER_RANK[tool.tier] ?? Number.POSITIVE_INFINITY
26
+ const max = tiers[actor?.kind] ?? 0
27
+ if (rank <= max) return 'allow'
28
+ if (tool.tier === 'destructive' && actor?.kind === 'agent') return 'approval'
29
+ return 'deny'
30
+ }
31
+
32
+ /**
33
+ * LLM tool manifest visible to the actor (everything it may run OR request approval for).
34
+ * @param {object[]} catalog tool definitions
35
+ * @param {Record<string, number>|undefined} actorTiers max executable tier rank per actor kind
36
+ * @param {{ kind?: string }} actor caller identity
37
+ * @returns {object[]} OpenAI tools array
38
+ */
39
+ export function scopedManifest(catalog, actorTiers, actor) {
40
+ return toolManifest(catalog, tool => classify(catalog, actorTiers, actor, tool.name) !== 'deny')
41
+ }
42
+
43
+ /**
44
+ * Tool names visible to the actor.
45
+ * @param {object[]} catalog tool definitions
46
+ * @param {Record<string, number>|undefined} actorTiers max executable tier rank per actor kind
47
+ * @param {{ kind?: string }} actor caller identity
48
+ * @returns {string[]} visible tool names
49
+ */
50
+ export function scopedToolNames(catalog, actorTiers, actor) {
51
+ return catalog.filter(tool => classify(catalog, actorTiers, actor, tool.name) !== 'deny').map(tool => tool.name)
52
+ }
@@ -0,0 +1,14 @@
1
+ // Catalog helpers. A catalog is the app-provided array of tool definitions
2
+ // (`{ tier, name, summary, input, tauri, validate? }`). The package treats it as
3
+ // data passed in — never a module-level singleton — so each consumer app keeps
4
+ // its own domain tool surface while sharing the agent machinery.
5
+
6
+ /**
7
+ * Look up a tool by name in a catalog.
8
+ * @param {object[]} catalog tool definitions
9
+ * @param {string} name tool name
10
+ * @returns {object|null} the tool definition, or null if unknown
11
+ */
12
+ export function getTool(catalog, name) {
13
+ return catalog.find(tool => tool.name === name) ?? null
14
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // `@7n/tauri-components` — core (no Vue). The agent loop, tool surface and the
2
+ // kit that binds them to an app catalog. Import this entry from headless
3
+ // consumers (CLI orchestrators, MCP wrappers); the Vue/Quasar layers live under
4
+ // the ./vue and ./components subpaths.
5
+
6
+ export { createAgentKit } from './core/agent-kit.js'
7
+ export { handleApprove, handleRequest, handleRespond } from './core/agent-handler.js'
8
+ export { createDispatch, validateInput } from './core/dispatch.js'
9
+ export { createOpenAiChat, runAgent } from './core/llm.js'
10
+ export { listTools, toJsonSchema, toolManifest } from './core/manifest.js'
11
+ export { classify, DEFAULT_ACTOR_TIERS, scopedManifest, scopedToolNames } from './core/scope.js'
12
+ export { getTool } from './core/tools.js'
@@ -0,0 +1,9 @@
1
+ // `@7n/tauri-components/vue` — Vue + Tauri composables. Wires the core agent kit
2
+ // to the webview (omlx over tauri-http, tools/journal over Tauri invoke).
3
+ // Requires the host app to provide vue + @tauri-apps/* (peer deps) and the
4
+ // tauri-plugin-agent backend commands.
5
+
6
+ export { useAgent } from './use-agent.js'
7
+ export { useOmlx } from './use-omlx.js'
8
+ export { createTauriJournalStore } from './journal-store-tauri.js'
9
+ export { tauriTransport } from './transports.js'
@@ -0,0 +1,17 @@
1
+ import { invoke } from '@tauri-apps/api/core'
2
+
3
+ // Webview journal store: routes journal FS through Rust Tauri commands
4
+ // (journal_*), provided by the shared `tauri-plugin-agent` (see SPEC §6). Same
5
+ // shape any other store implements, so agent-handler stays backend-agnostic.
6
+
7
+ /**
8
+ * @returns {{ create: (r:object)=>Promise<string>, load: (id:string)=>Promise<object>, update: (id:string,patch:object)=>Promise<void>, list: ()=>Promise<object[]> }} journal store
9
+ */
10
+ export function createTauriJournalStore() {
11
+ return {
12
+ create: ({ intent, actor }) => invoke('journal_create', { intent, actor }),
13
+ load: id => invoke('journal_load', { id }),
14
+ update: (id, patch) => invoke('journal_update', { id, patch }),
15
+ list: () => invoke('journal_list'),
16
+ }
17
+ }
@@ -0,0 +1,14 @@
1
+ import { invoke } from '@tauri-apps/api/core'
2
+
3
+ // UI transport: route a tool call to its Tauri command. Input keys map 1:1 to
4
+ // the command's args (already camelCase, e.g. tasksDir). Each consumer app
5
+ // implements the named `tool.tauri` commands in its Rust backend.
6
+
7
+ /**
8
+ * @param {object} tool tool definition (uses `tool.tauri`)
9
+ * @param {object} input tool input, forwarded as command args
10
+ * @returns {Promise<unknown>} the command result
11
+ */
12
+ export function tauriTransport(tool, input) {
13
+ return invoke(tool.tauri, input)
14
+ }
@@ -0,0 +1,57 @@
1
+ import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
2
+ import { createAgentKit } from '../core/agent-kit.js'
3
+ import { createOpenAiChat } from '../core/llm.js'
4
+ import { createTauriJournalStore } from './journal-store-tauri.js'
5
+ import { tauriTransport } from './transports.js'
6
+ import { useOmlx } from './use-omlx.js'
7
+
8
+ // In-app agent gateway: binds an app's catalog/prompt to the webview transports —
9
+ // omlx via tauri-http, tools via Tauri invoke, journal via the tauri-plugin-agent
10
+ // commands. Returns a flat surface the dialogs consume: request(intent),
11
+ // respond(id, msg), approve(id, ok) plus the omlx config refs and the journal.
12
+ //
13
+ // Apps pass their catalog + systemPrompt (+ optional grounding/omlx defaults);
14
+ // everything Tauri-specific is wired here so feature dialogs stay domain-free.
15
+
16
+ const DEFAULT_ACTOR = { kind: 'human', id: 'local' }
17
+
18
+ /**
19
+ * @param {object} config gateway config
20
+ * @param {object[]} config.catalog app tool catalog (required)
21
+ * @param {string|((ctx: object) => string)} [config.systemPrompt] domain prompt or builder
22
+ * @param {{ tool: string, key?: string, fallback?: unknown }} [config.grounding] optional prompt grounding
23
+ * @param {Record<string, number>} [config.actorTiers] tier overrides
24
+ * @param {{ kind: string, id: string }} [config.actor] caller identity (default human:local)
25
+ * @param {{ storagePrefix?: string, defaultBaseUrl?: string, defaultModel?: string }} [config.omlx] omlx config options
26
+ * @returns {object} in-app agent gateway
27
+ */
28
+ export function useAgent({ catalog, systemPrompt, grounding, actorTiers, actor = DEFAULT_ACTOR, omlx } = {}) {
29
+ const { baseUrl, model, apiKey, save, loadEnv } = useOmlx(omlx)
30
+ const journal = createTauriJournalStore()
31
+ const kit = createAgentKit({ catalog, systemPrompt, transport: tauriTransport, journal, actorTiers, grounding })
32
+
33
+ /**
34
+ * Build an omlx chat fn from the current config (tauri-http transport).
35
+ * @returns {(req: object) => Promise<object>} chat function
36
+ */
37
+ function chat() {
38
+ return createOpenAiChat({
39
+ baseUrl: baseUrl.value,
40
+ model: model.value,
41
+ apiKey: apiKey.value || undefined,
42
+ fetchFn: tauriFetch,
43
+ })
44
+ }
45
+
46
+ return {
47
+ baseUrl,
48
+ model,
49
+ apiKey,
50
+ saveOmlx: save,
51
+ loadOmlxEnv: loadEnv,
52
+ journal,
53
+ request: intent => kit.request({ intent, actor, chat: chat() }),
54
+ respond: (requestId, message) => kit.respond({ requestId, message, actor, chat: chat() }),
55
+ approve: (requestId, approve) => kit.approve({ requestId, approve }),
56
+ }
57
+ }
@@ -0,0 +1,60 @@
1
+ import { ref } from 'vue'
2
+ import { invoke } from '@tauri-apps/api/core'
3
+
4
+ // Persisted config for the local omlx server (OpenAI-compatible MLX) that drives
5
+ // the in-app agent. The API key / base URL come from Rust (omlx_config), which
6
+ // reads them from ~/.omlx/settings.json — the same file the omlx server uses, so
7
+ // the config is portable between machines without env/launchd setup. baseUrl is
8
+ // edited in the dialog and cached in localStorage; omlx_config is the default
9
+ // when localStorage is empty. Priority for base: localStorage > omlx_config >
10
+ // hardcoded default. The key always comes from omlx_config.
11
+ //
12
+ // storagePrefix namespaces the localStorage keys per app (so `task` and `mlmail`
13
+ // keep independent base/model). defaults seed first launch.
14
+
15
+ const DEFAULT_BASE_URL = 'http://127.0.0.1:8000/v1'
16
+
17
+ /**
18
+ * @param {{ storagePrefix?: string, defaultBaseUrl?: string, defaultModel?: string }} [options] config
19
+ * @returns {{ baseUrl: import('vue').Ref<string>, model: import('vue').Ref<string>, apiKey: import('vue').Ref<string>, save: () => void, loadEnv: () => Promise<void> }} persisted omlx config, an env loader and a saver
20
+ */
21
+ export function useOmlx({ storagePrefix = 'agent', defaultBaseUrl = DEFAULT_BASE_URL, defaultModel = '' } = {}) {
22
+ const baseUrlKey = `${storagePrefix}:omlxBaseUrl`
23
+ const modelKey = `${storagePrefix}:omlxModel`
24
+
25
+ const baseUrl = ref(localStorage.getItem(baseUrlKey) || defaultBaseUrl)
26
+ const model = ref(localStorage.getItem(modelKey) || defaultModel)
27
+ // Filled from the global env in loadEnv(); never persisted to localStorage.
28
+ const apiKey = ref('')
29
+
30
+ /**
31
+ * Pull OMLX_* from the user's global env (via Rust) and apply them: the API
32
+ * key is taken verbatim; baseUrl/model only fill in when localStorage is empty,
33
+ * so a value set in the dialog still wins. No-op outside Tauri (tests / web).
34
+ * @returns {Promise<void>}
35
+ */
36
+ async function loadEnv() {
37
+ let env
38
+ try {
39
+ env = await invoke('omlx_config')
40
+ }
41
+ catch {
42
+ return // not running under Tauri — keep localStorage / defaults
43
+ }
44
+ if (!env) return
45
+ if (env.apiKey) apiKey.value = env.apiKey
46
+ if (env.baseUrl && !localStorage.getItem(baseUrlKey)) baseUrl.value = env.baseUrl
47
+ if (env.model && !localStorage.getItem(modelKey)) model.value = env.model
48
+ }
49
+
50
+ /**
51
+ * Persist baseUrl/model to localStorage. The API key is intentionally NOT
52
+ * persisted — it comes from the global OMLX_API_KEY env on each launch.
53
+ */
54
+ function save() {
55
+ localStorage.setItem(baseUrlKey, baseUrl.value)
56
+ localStorage.setItem(modelKey, model.value)
57
+ }
58
+
59
+ return { baseUrl, model, apiKey, save, loadEnv }
60
+ }