@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 +45 -0
- package/src/components/AgentDialog.vue +170 -0
- package/src/components/AuditDialog.vue +259 -0
- package/src/components/BaseDialog.vue +58 -0
- package/src/components/DialogActions.vue +26 -0
- package/src/components/RequestView.vue +101 -0
- package/src/components/index.js +10 -0
- package/src/core/agent-handler.js +171 -0
- package/src/core/agent-kit.js +43 -0
- package/src/core/dispatch.js +50 -0
- package/src/core/llm.js +90 -0
- package/src/core/manifest.js +44 -0
- package/src/core/scope.js +52 -0
- package/src/core/tools.js +14 -0
- package/src/index.js +12 -0
- package/src/vue/index.js +9 -0
- package/src/vue/journal-store-tauri.js +17 -0
- package/src/vue/transports.js +14 -0
- package/src/vue/use-agent.js +57 -0
- package/src/vue/use-omlx.js +60 -0
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
|
+
}
|
package/src/core/llm.js
ADDED
|
@@ -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'
|
package/src/vue/index.js
ADDED
|
@@ -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
|
+
}
|