@doderasoftware/restify-ai 0.1.0-beta.5 โ 0.1.0-beta.7
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/README.md +856 -439
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,631 +1,1048 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/TailwindCSS-3.x-38bdf8.svg?style=flat-square" alt="TailwindCSS"></a>
|
|
12
|
-
|
|
13
|
-
<br /><br />
|
|
14
|
-
|
|
15
|
-
<a href="https://laravel-restify.com">Laravel Restify</a> โข
|
|
16
|
-
<a href="https://binarcode.com">BinarCode</a> โข
|
|
17
|
-
<a href="#features">Features</a> โข
|
|
18
|
-
<a href="#installation">Installation</a> โข
|
|
19
|
-
<a href="#quick-start">Quick Start</a> โข
|
|
20
|
-
<a href="#configuration">Configuration</a>
|
|
21
|
-
</div>
|
|
1
|
+
# @doderasoftware/restify-ai
|
|
2
|
+
|
|
3
|
+
A production-ready AI chatbot component for Vue 3 with real-time SSE streaming, file attachments, @mentions, and seamless [Laravel Restify](https://laravel-restify.com) integration.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@doderasoftware/restify-ai)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://vuejs.org/)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
|
|
10
|
+
**๐ [Laravel Restify](https://laravel-restify.com) | ๐ฆ [npm](https://www.npmjs.com/package/@doderasoftware/restify-ai) | ๐ข [BinarCode](https://binarcode.com)**
|
|
22
11
|
|
|
23
12
|
---
|
|
24
13
|
|
|
25
|
-
##
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
-
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
- **
|
|
44
|
-
- **
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- **Fullscreen Mode** - Expandable chat interface
|
|
48
|
-
- **Animations** - Smooth transitions and loading states
|
|
49
|
-
- **Customizable Avatars** - Use custom components or images
|
|
50
|
-
|
|
51
|
-
### ๐ง Developer Experience
|
|
52
|
-
- **TypeScript First** - Full type definitions included
|
|
53
|
-
- **Vue 3 Composition API** - Modern Vue patterns
|
|
54
|
-
- **Pinia Integration** - State management built-in
|
|
55
|
-
- **Slot-Based Customization** - Override any component section
|
|
56
|
-
- **Lifecycle Hooks** - Tap into every stage of the chat flow
|
|
57
|
-
- **i18n Ready** - Full internationalization support
|
|
58
|
-
|
|
59
|
-
### ๐ Enterprise Ready
|
|
60
|
-
- **Support Mode** - Route conversations to human agents
|
|
61
|
-
- **Permission System** - Control features based on user permissions
|
|
62
|
-
- **Request/Response Interceptors** - Customize API communication
|
|
63
|
-
- **Retry Logic** - Automatic retry with configurable backoff
|
|
64
|
-
- **Error Handling** - User-friendly error messages
|
|
65
|
-
|
|
66
|
-
## Installation
|
|
14
|
+
## โจ Features
|
|
15
|
+
|
|
16
|
+
- ๐ **Real-time SSE Streaming** - Smooth character-by-character response streaming
|
|
17
|
+
- ๐ **File Attachments** - Upload and process documents, images, and more
|
|
18
|
+
- ๐ฅ **@Mentions System** - Reference entities from your application (employees, jobs, projects, etc.)
|
|
19
|
+
- ๐ก **Context-Aware Suggestions** - Smart prompts based on current page/route
|
|
20
|
+
- ๐ฌ **Chat History** - Persistent conversation memory with configurable limits
|
|
21
|
+
- ๐ **Markdown Rendering** - Beautiful formatting with syntax highlighting
|
|
22
|
+
- ๐ **Quota Management** - Track and display API usage limits
|
|
23
|
+
- ๐จ **Fully Customizable** - Override any style with CSS classes
|
|
24
|
+
- ๐ **Dark Mode Support** - Automatic dark/light theme detection
|
|
25
|
+
- ๐ฑ **Responsive Design** - Works on desktop, tablet, and mobile
|
|
26
|
+
- โจ๏ธ **Keyboard Shortcuts** - Quick access with configurable shortcuts
|
|
27
|
+
- ๐ณ **Fullscreen Mode** - Expandable chat interface
|
|
28
|
+
- ๐ฏ **TypeScript First** - Full type definitions included
|
|
29
|
+
- ๐๏ธ **Pinia Integration** - State management built-in
|
|
30
|
+
- ๐ง **Slot-Based Customization** - Override any component section
|
|
31
|
+
- ๐ **i18n Ready** - Full internationalization support
|
|
32
|
+
- ๐ **Support Mode** - Route conversations to human support
|
|
33
|
+
- ๐ **Retry Logic** - Automatic retry with configurable backoff
|
|
34
|
+
|
|
35
|
+
## ๐ฆ Installation
|
|
67
36
|
|
|
68
37
|
```bash
|
|
69
|
-
# npm
|
|
70
38
|
npm install @doderasoftware/restify-ai
|
|
71
|
-
|
|
72
|
-
# yarn
|
|
73
|
-
yarn add @doderasoftware/restify-ai
|
|
74
|
-
|
|
75
|
-
# pnpm
|
|
76
|
-
pnpm add @doderasoftware/restify-ai
|
|
77
39
|
```
|
|
78
40
|
|
|
79
41
|
### Peer Dependencies
|
|
80
42
|
|
|
81
43
|
```bash
|
|
82
|
-
npm install vue@^3.3.0 pinia@^2.1.0
|
|
44
|
+
npm install vue@^3.3.0 pinia@^2.1.0
|
|
83
45
|
```
|
|
84
46
|
|
|
85
|
-
## Quick Start
|
|
86
|
-
|
|
87
|
-
### 1. Configure Tailwind CSS
|
|
88
|
-
|
|
89
|
-
```javascript
|
|
90
|
-
// tailwind.config.js
|
|
91
|
-
export default {
|
|
92
|
-
content: [
|
|
93
|
-
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
|
94
|
-
"./node_modules/@doderasoftware/restify-ai/dist/**/*.{js,vue}",
|
|
95
|
-
],
|
|
96
|
-
presets: [
|
|
97
|
-
require("@doderasoftware/restify-ai/tailwind"),
|
|
98
|
-
],
|
|
99
|
-
}
|
|
100
|
-
```
|
|
47
|
+
## ๐ Quick Start
|
|
101
48
|
|
|
102
|
-
###
|
|
49
|
+
### 1. Import Styles
|
|
103
50
|
|
|
104
51
|
```typescript
|
|
105
52
|
// main.ts
|
|
106
|
-
import
|
|
53
|
+
import '@doderasoftware/restify-ai/styles'
|
|
107
54
|
```
|
|
108
55
|
|
|
109
|
-
###
|
|
56
|
+
### 2. Create Plugin Configuration
|
|
110
57
|
|
|
111
58
|
```typescript
|
|
112
59
|
// plugins/restifyAi.ts
|
|
113
|
-
import {
|
|
114
|
-
import
|
|
60
|
+
import type { App } from 'vue'
|
|
61
|
+
import { RestifyAiPlugin } from '@doderasoftware/restify-ai'
|
|
62
|
+
import '@doderasoftware/restify-ai/styles'
|
|
115
63
|
|
|
116
64
|
export function setupRestifyAi(app: App) {
|
|
117
65
|
app.use(RestifyAiPlugin, {
|
|
118
66
|
endpoints: {
|
|
119
|
-
ask:
|
|
120
|
-
uploadFile:
|
|
121
|
-
quota:
|
|
67
|
+
ask: '/ask',
|
|
68
|
+
uploadFile: '/ai/upload',
|
|
69
|
+
quota: '/ai/quota',
|
|
122
70
|
},
|
|
123
|
-
getAuthToken: () => localStorage.getItem("token"),
|
|
124
71
|
baseUrl: import.meta.env.VITE_API_URL,
|
|
72
|
+
getAuthToken: () => localStorage.getItem('token'),
|
|
125
73
|
})
|
|
126
74
|
}
|
|
127
75
|
```
|
|
128
76
|
|
|
77
|
+
### 3. Register in Main
|
|
78
|
+
|
|
129
79
|
```typescript
|
|
130
80
|
// main.ts
|
|
131
|
-
import { createApp } from
|
|
132
|
-
import { createPinia } from
|
|
133
|
-
import App from
|
|
134
|
-
import { setupRestifyAi } from
|
|
135
|
-
import
|
|
81
|
+
import { createApp } from 'vue'
|
|
82
|
+
import { createPinia } from 'pinia'
|
|
83
|
+
import App from './App.vue'
|
|
84
|
+
import { setupRestifyAi } from './plugins/restifyAi'
|
|
85
|
+
import '@doderasoftware/restify-ai/styles'
|
|
136
86
|
|
|
137
87
|
const app = createApp(App)
|
|
138
88
|
app.use(createPinia())
|
|
139
89
|
setupRestifyAi(app)
|
|
140
|
-
app.mount(
|
|
90
|
+
app.mount('#app')
|
|
141
91
|
```
|
|
142
92
|
|
|
143
93
|
### 4. Add the Component
|
|
144
94
|
|
|
145
95
|
```vue
|
|
146
96
|
<template>
|
|
147
|
-
<
|
|
148
|
-
<button @click="showChat = true" class="fixed bottom-4 right-4 p-3 bg-blue-600 rounded-full">
|
|
149
|
-
<SparklesIcon class="w-6 h-6 text-white" />
|
|
150
|
-
</button>
|
|
151
|
-
<AiChatDrawer v-model="showChat" />
|
|
152
|
-
</div>
|
|
97
|
+
<AiChatDrawer v-model="aiStore.showChat" top-offset="50px" />
|
|
153
98
|
</template>
|
|
154
99
|
|
|
155
100
|
<script setup lang="ts">
|
|
156
|
-
import {
|
|
157
|
-
import { AiChatDrawer } from "@doderasoftware/restify-ai"
|
|
101
|
+
import { AiChatDrawer, useRestifyAiStore } from '@doderasoftware/restify-ai'
|
|
158
102
|
|
|
159
|
-
const
|
|
103
|
+
const aiStore = useRestifyAiStore()
|
|
160
104
|
</script>
|
|
161
105
|
```
|
|
162
106
|
|
|
163
|
-
### 5. Enable Keyboard Shortcut
|
|
107
|
+
### 5. Enable Keyboard Shortcut (Optional)
|
|
164
108
|
|
|
165
109
|
```typescript
|
|
166
|
-
|
|
110
|
+
// In your layout or App.vue
|
|
111
|
+
import { useAiDrawerShortcut } from '@doderasoftware/restify-ai'
|
|
167
112
|
|
|
168
|
-
//
|
|
169
|
-
useAiDrawerShortcut()
|
|
113
|
+
useAiDrawerShortcut() // Enables Cmd/Ctrl+G to toggle
|
|
170
114
|
```
|
|
171
115
|
|
|
172
|
-
## Configuration
|
|
116
|
+
## โ๏ธ Configuration
|
|
173
117
|
|
|
174
118
|
### Full Configuration Options
|
|
175
119
|
|
|
176
120
|
```typescript
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// FEATURES
|
|
228
|
-
keyboardShortcut: "cmd+g",
|
|
229
|
-
enableSupportMode: true,
|
|
230
|
-
|
|
231
|
-
// CUSTOM COMPONENTS
|
|
232
|
-
assistantAvatar: CustomAvatarComponent,
|
|
233
|
-
userAvatar: () => currentUser.value?.avatarUrl,
|
|
234
|
-
|
|
235
|
-
// CALLBACKS
|
|
236
|
-
onMessageSent: (msg) => analytics.track("ai_sent"),
|
|
237
|
-
onError: (err) => Sentry.captureException(err),
|
|
238
|
-
onStreamStart: () => console.log("Stream started"),
|
|
239
|
-
beforeSend: (payload) => ({ ...payload, timestamp: Date.now() }),
|
|
240
|
-
})
|
|
241
|
-
```
|
|
121
|
+
import type { App } from 'vue'
|
|
122
|
+
import { RestifyAiPlugin } from '@doderasoftware/restify-ai'
|
|
123
|
+
import type { MentionProvider, SuggestionProvider, AISuggestion } from '@doderasoftware/restify-ai'
|
|
124
|
+
|
|
125
|
+
export function setupRestifyAi(app: App) {
|
|
126
|
+
app.use(RestifyAiPlugin, {
|
|
127
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
128
|
+
// REQUIRED
|
|
129
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
130
|
+
|
|
131
|
+
endpoints: {
|
|
132
|
+
ask: '/ask', // SSE streaming endpoint
|
|
133
|
+
uploadFile: '/ai/upload', // File upload endpoint
|
|
134
|
+
quota: '/ai/quota', // Quota fetch endpoint
|
|
135
|
+
},
|
|
136
|
+
getAuthToken: () => localStorage.getItem('token'),
|
|
137
|
+
|
|
138
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
139
|
+
// API CONFIGURATION
|
|
140
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
141
|
+
|
|
142
|
+
baseUrl: 'https://api.example.com',
|
|
143
|
+
|
|
144
|
+
// Custom headers for every request
|
|
145
|
+
getCustomHeaders: () => ({
|
|
146
|
+
'X-Tenant-ID': getTenantId(),
|
|
147
|
+
'Accept-Language': getCurrentLocale(),
|
|
148
|
+
}),
|
|
149
|
+
|
|
150
|
+
// Transform request payload before sending
|
|
151
|
+
buildRequest: (payload) => ({
|
|
152
|
+
...payload,
|
|
153
|
+
customField: 'value',
|
|
154
|
+
}),
|
|
155
|
+
|
|
156
|
+
// Parse SSE stream content (default handles OpenAI format)
|
|
157
|
+
parseStreamContent: (data) => {
|
|
158
|
+
const parsed = JSON.parse(data)
|
|
159
|
+
return parsed.choices?.[0]?.delta?.content || null
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
163
|
+
// RETRY CONFIGURATION
|
|
164
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
165
|
+
|
|
166
|
+
retry: {
|
|
167
|
+
maxRetries: 3,
|
|
168
|
+
retryDelay: 1000,
|
|
169
|
+
shouldRetry: (error, attempt) => attempt < 3,
|
|
170
|
+
},
|
|
242
171
|
|
|
243
|
-
|
|
172
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
173
|
+
// INTERNATIONALIZATION
|
|
174
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
175
|
+
|
|
176
|
+
// Translate function for i18n integration
|
|
177
|
+
translate: (key, params) => i18n.t(key, params),
|
|
178
|
+
|
|
179
|
+
// Permission check function
|
|
180
|
+
can: (permission) => userCan(permission),
|
|
181
|
+
|
|
182
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
183
|
+
// LABELS (all customizable)
|
|
184
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
185
|
+
|
|
186
|
+
labels: {
|
|
187
|
+
title: 'AI Assistant',
|
|
188
|
+
aiName: 'AI Assistant',
|
|
189
|
+
you: 'You',
|
|
190
|
+
newChat: 'New chat',
|
|
191
|
+
placeholder: 'Ask me anything...',
|
|
192
|
+
inputPlaceholder: 'Ask me anything...',
|
|
193
|
+
supportPlaceholder: 'Describe your issue...',
|
|
194
|
+
loadingText: 'Gathering data...',
|
|
195
|
+
analyzingText: 'Analyzing...',
|
|
196
|
+
craftingText: 'Crafting response...',
|
|
197
|
+
quotaRemaining: 'questions remaining',
|
|
198
|
+
noQuota: 'No AI credit available',
|
|
199
|
+
contactSupport: 'Contact Support',
|
|
200
|
+
close: 'Close',
|
|
201
|
+
minimize: 'Minimize',
|
|
202
|
+
fullscreen: 'Fullscreen',
|
|
203
|
+
exitFullscreen: 'Exit fullscreen',
|
|
204
|
+
copyToClipboard: 'Copy to clipboard',
|
|
205
|
+
copied: 'Content copied to clipboard',
|
|
206
|
+
showMore: 'Show more',
|
|
207
|
+
showLess: 'Show less',
|
|
208
|
+
retry: 'Retry',
|
|
209
|
+
attachFiles: 'Attach files',
|
|
210
|
+
emptyStateTitle: 'How can I help you today?',
|
|
211
|
+
emptyStateDescription: 'Ask questions or get help with tasks',
|
|
212
|
+
keyboardShortcutHint: 'Press โG to toggle',
|
|
213
|
+
sendMessage: 'Send message',
|
|
214
|
+
attachFile: 'Attach file',
|
|
215
|
+
closeConfirmTitle: 'Close chat window?',
|
|
216
|
+
closeConfirmMessage: 'You will lose the conversation.',
|
|
217
|
+
confirmClose: 'Yes, close it',
|
|
218
|
+
cancel: 'Cancel',
|
|
219
|
+
toggleSupportMode: 'Contact Support',
|
|
220
|
+
exitSupportMode: 'Exit Support Mode',
|
|
221
|
+
historyLimitReachedTitle: 'Conversation Limit Reached',
|
|
222
|
+
historyLimitReachedMessage: 'Maximum messages reached.',
|
|
223
|
+
startNewChat: 'New Chat',
|
|
224
|
+
},
|
|
244
225
|
|
|
245
|
-
|
|
226
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
227
|
+
// MENTION PROVIDERS
|
|
228
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
229
|
+
|
|
230
|
+
mentionProviders: createMentionProviders(),
|
|
231
|
+
|
|
232
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
233
|
+
// SUGGESTION PROVIDERS
|
|
234
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
235
|
+
|
|
236
|
+
suggestionProviders: createSuggestionProviders(),
|
|
237
|
+
defaultSuggestions: getDefaultSuggestions(),
|
|
238
|
+
|
|
239
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
240
|
+
// THEME
|
|
241
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
242
|
+
|
|
243
|
+
theme: {
|
|
244
|
+
primaryColor: '#3b82f6',
|
|
245
|
+
primaryLightColor: '#60a5fa',
|
|
246
|
+
userBubbleColor: '#3b82f6',
|
|
247
|
+
userTextColor: '#ffffff',
|
|
248
|
+
borderColor: '#e5e7eb',
|
|
249
|
+
drawerWidth: '600px',
|
|
250
|
+
drawerFullscreenWidth: '90vw',
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
254
|
+
// LIMITS
|
|
255
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
256
|
+
|
|
257
|
+
chatHistoryLimit: 15,
|
|
258
|
+
maxAttachments: 5,
|
|
259
|
+
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
260
|
+
acceptedFileTypes: 'image/*,.pdf,.txt,.doc,.docx,.xls,.xlsx,.csv',
|
|
261
|
+
|
|
262
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
263
|
+
// STORAGE
|
|
264
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
265
|
+
|
|
266
|
+
chatHistoryKey: 'app_chat_history',
|
|
267
|
+
drawerStateKey: 'app_chat_drawer_open',
|
|
268
|
+
|
|
269
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
270
|
+
// FEATURES
|
|
271
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
272
|
+
|
|
273
|
+
keyboardShortcut: 'mod+g', // 'mod' = Cmd on Mac, Ctrl on Windows
|
|
274
|
+
enableSupportMode: true,
|
|
275
|
+
canToggle: () => true,
|
|
276
|
+
|
|
277
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
278
|
+
// AVATARS
|
|
279
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
280
|
+
|
|
281
|
+
assistantAvatar: CustomAiAvatarComponent,
|
|
282
|
+
userAvatar: () => authStore.profile?.avatar || null,
|
|
283
|
+
|
|
284
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
285
|
+
// CALLBACKS
|
|
286
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
287
|
+
|
|
288
|
+
onError: (error) => console.error('AI Error:', error),
|
|
289
|
+
onQuotaFetched: (quota) => console.log('Quota:', quota),
|
|
290
|
+
onMessageSent: (message) => analytics.track('ai_message_sent'),
|
|
291
|
+
onResponseReceived: (message) => console.log('Response:', message),
|
|
292
|
+
onDrawerToggle: (isOpen) => console.log('Drawer:', isOpen),
|
|
293
|
+
onNewChat: () => console.log('New chat started'),
|
|
294
|
+
|
|
295
|
+
// Stream lifecycle hooks
|
|
296
|
+
onStreamStart: () => console.log('Stream started'),
|
|
297
|
+
onStreamEnd: (fullMessage) => console.log('Stream ended'),
|
|
298
|
+
onStreamChunk: (chunk) => console.log('Chunk:', chunk),
|
|
299
|
+
beforeSend: (payload) => payload,
|
|
300
|
+
afterResponse: (message) => {},
|
|
301
|
+
|
|
302
|
+
// File upload hooks
|
|
303
|
+
onFileUploadStart: (file) => {},
|
|
304
|
+
onFileUploadProgress: (file, progress) => {},
|
|
305
|
+
onFileUploadComplete: (file) => {},
|
|
306
|
+
onFileUploadError: (file, error) => {},
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## ๐ AiChatDrawer Props
|
|
246
312
|
|
|
247
313
|
| Prop | Type | Default | Description |
|
|
248
314
|
|------|------|---------|-------------|
|
|
249
315
|
| `modelValue` | `boolean` | required | Controls drawer visibility (v-model) |
|
|
250
316
|
| `width` | `string` | `"600px"` | Drawer width |
|
|
251
|
-
| `fullscreenWidth` | `string` | `"90vw"` | Width when fullscreen |
|
|
252
|
-
| `topOffset` | `string` | `"0"` | Top offset
|
|
317
|
+
| `fullscreenWidth` | `string` | `"90vw"` | Width when in fullscreen mode |
|
|
318
|
+
| `topOffset` | `string` | `"0"` | Top offset for fixed headers |
|
|
253
319
|
| `position` | `"left" \| "right"` | `"right"` | Drawer position |
|
|
254
320
|
| `showBackdrop` | `boolean` | `false` | Show backdrop overlay |
|
|
321
|
+
| `closeOnBackdropClick` | `boolean` | `false` | Close when clicking backdrop |
|
|
255
322
|
| `closeOnEscape` | `boolean` | `true` | Close on Escape key |
|
|
256
323
|
| `showQuota` | `boolean` | `true` | Show quota display |
|
|
257
|
-
| `
|
|
324
|
+
| `showFullscreenToggle` | `boolean` | `true` | Show fullscreen button |
|
|
325
|
+
| `showMinimizeButton` | `boolean` | `true` | Show minimize button |
|
|
326
|
+
| `showCloseButton` | `boolean` | `true` | Show close button |
|
|
327
|
+
| `showNewChatButton` | `boolean` | `true` | Show new chat button |
|
|
328
|
+
| `confirmClose` | `boolean` | `true` | Confirm before clearing history |
|
|
329
|
+
| `autoFetchQuota` | `boolean` | `true` | Auto-fetch quota when opened |
|
|
330
|
+
| `historyLimit` | `HistoryLimitConfig` | - | History limit configuration |
|
|
331
|
+
| `loadingText` | `LoadingTextConfig` | - | Loading text configuration |
|
|
258
332
|
| `ui` | `AiChatDrawerUI` | `{}` | Custom CSS classes |
|
|
259
333
|
| `texts` | `AiChatDrawerTexts` | `{}` | Custom text labels |
|
|
260
334
|
|
|
261
|
-
###
|
|
335
|
+
### Component Usage Example
|
|
336
|
+
|
|
337
|
+
```vue
|
|
338
|
+
<template>
|
|
339
|
+
<AiChatDrawer
|
|
340
|
+
v-model="aiStore.showChat"
|
|
341
|
+
top-offset="50px"
|
|
342
|
+
:show-backdrop="false"
|
|
343
|
+
:confirm-close="true"
|
|
344
|
+
:show-quota="true"
|
|
345
|
+
@contact-support="handleContactSupport"
|
|
346
|
+
>
|
|
347
|
+
<template #context-link>
|
|
348
|
+
<p class="text-center text-xs text-gray-500">
|
|
349
|
+
For accurate results, provide
|
|
350
|
+
<router-link to="/settings/ai-context" class="text-primary-600 hover:underline">
|
|
351
|
+
company context
|
|
352
|
+
</router-link>
|
|
353
|
+
</p>
|
|
354
|
+
</template>
|
|
355
|
+
</AiChatDrawer>
|
|
356
|
+
</template>
|
|
357
|
+
|
|
358
|
+
<script setup lang="ts">
|
|
359
|
+
import { AiChatDrawer, useRestifyAiStore, useAiDrawerShortcut } from '@doderasoftware/restify-ai'
|
|
360
|
+
|
|
361
|
+
const aiStore = useRestifyAiStore()
|
|
362
|
+
|
|
363
|
+
useAiDrawerShortcut()
|
|
364
|
+
|
|
365
|
+
function handleContactSupport() {
|
|
366
|
+
// Handle support request
|
|
367
|
+
}
|
|
368
|
+
</script>
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## ๐ก Events
|
|
262
372
|
|
|
263
373
|
| Event | Payload | Description |
|
|
264
374
|
|-------|---------|-------------|
|
|
265
375
|
| `update:modelValue` | `boolean` | Drawer state changed |
|
|
266
|
-
| `close` | - | Drawer closed |
|
|
376
|
+
| `close` | - | Drawer was closed |
|
|
267
377
|
| `contact-support` | - | Support mode activated |
|
|
268
378
|
| `new-chat` | - | New chat started |
|
|
269
379
|
|
|
270
|
-
|
|
380
|
+
## ๐ฐ Slots
|
|
271
381
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
<MyCustomMessage />
|
|
282
|
-
</template>
|
|
283
|
-
<template #input="{ modelValue, sending, onSubmit }">
|
|
284
|
-
<MyCustomInput />
|
|
285
|
-
</template>
|
|
286
|
-
</AiChatDrawer>
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
## UI Customization
|
|
290
|
-
|
|
291
|
-
Every component accepts a `ui` prop for CSS class customization:
|
|
382
|
+
| Slot | Props | Description |
|
|
383
|
+
|------|-------|-------------|
|
|
384
|
+
| `header` | `HeaderSlotProps` | Custom header content |
|
|
385
|
+
| `quota` | `{ quota: ChatQuota }` | Custom quota display |
|
|
386
|
+
| `setup` | - | Custom setup guide |
|
|
387
|
+
| `empty-state` | `{ suggestions, onClick }` | Custom empty state |
|
|
388
|
+
| `message` | `MessageSlotProps` | Custom message bubble |
|
|
389
|
+
| `input` | `InputSlotProps` | Custom input area |
|
|
390
|
+
| `context-link` | - | Custom context link below input |
|
|
292
391
|
|
|
293
|
-
|
|
294
|
-
<AiChatDrawer
|
|
295
|
-
v-model="isOpen"
|
|
296
|
-
:ui="{
|
|
297
|
-
backdrop: "bg-black/50 backdrop-blur-sm",
|
|
298
|
-
drawer: "shadow-2xl",
|
|
299
|
-
panel: "bg-gray-50 dark:bg-gray-900",
|
|
300
|
-
header: "border-b-2 border-blue-500",
|
|
301
|
-
newChatButton: "bg-gradient-to-r from-blue-500 to-purple-500",
|
|
302
|
-
}"
|
|
303
|
-
/>
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
### Available UI Interfaces
|
|
392
|
+
### Slot Props Types
|
|
307
393
|
|
|
308
394
|
```typescript
|
|
309
|
-
interface
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
newChatButton?: string
|
|
318
|
-
errorContainer?: string
|
|
319
|
-
retryButton?: string
|
|
395
|
+
interface HeaderSlotProps {
|
|
396
|
+
quota: ChatQuota
|
|
397
|
+
isFullscreen: boolean
|
|
398
|
+
hasHistory: boolean
|
|
399
|
+
onNewChat: () => void
|
|
400
|
+
onClose: () => void
|
|
401
|
+
onMinimize: () => void
|
|
402
|
+
onToggleFullscreen: () => void
|
|
320
403
|
}
|
|
321
404
|
|
|
322
|
-
interface
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
suggestionsDropdown?: string
|
|
405
|
+
interface MessageSlotProps {
|
|
406
|
+
message: ChatMessage
|
|
407
|
+
isUser: boolean
|
|
408
|
+
isLoading: boolean
|
|
409
|
+
isStreaming: boolean
|
|
328
410
|
}
|
|
329
411
|
|
|
330
|
-
interface
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
412
|
+
interface InputSlotProps {
|
|
413
|
+
modelValue: string
|
|
414
|
+
sending: boolean
|
|
415
|
+
disabled: boolean
|
|
416
|
+
onSubmit: (payload: SubmitPayload) => void
|
|
417
|
+
onCancel: () => void
|
|
336
418
|
}
|
|
337
419
|
```
|
|
338
420
|
|
|
339
|
-
## Store API
|
|
340
|
-
|
|
341
|
-
Access the Pinia store for advanced use cases:
|
|
421
|
+
## ๐ช Store API
|
|
342
422
|
|
|
343
423
|
```typescript
|
|
344
|
-
import { useRestifyAiStore } from
|
|
424
|
+
import { useRestifyAiStore } from '@doderasoftware/restify-ai'
|
|
345
425
|
|
|
346
426
|
const store = useRestifyAiStore()
|
|
347
427
|
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
store.
|
|
353
|
-
store.
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
store.
|
|
357
|
-
store.
|
|
358
|
-
store.
|
|
359
|
-
store.
|
|
360
|
-
store.
|
|
361
|
-
|
|
362
|
-
|
|
428
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
429
|
+
// STATE
|
|
430
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
431
|
+
|
|
432
|
+
store.chatHistory // ChatMessage[] - All messages
|
|
433
|
+
store.showChat // boolean - Drawer visibility
|
|
434
|
+
store.sending // boolean - Message being sent
|
|
435
|
+
store.loading // boolean - Loading state
|
|
436
|
+
store.isFullscreen // boolean - Fullscreen mode
|
|
437
|
+
store.quota // { limit, used, remaining }
|
|
438
|
+
store.error // { message, failedQuestion, failedAttachments, timestamp }
|
|
439
|
+
store.supportRequestMode // boolean - Support mode active
|
|
440
|
+
store.pageContext // PageContext | null - Current page context
|
|
441
|
+
|
|
442
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
443
|
+
// GETTERS
|
|
444
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
445
|
+
|
|
446
|
+
store.hasMessages // boolean - Has any messages
|
|
447
|
+
store.isInSetupMode // boolean - In setup mode
|
|
448
|
+
store.canChat // boolean - Can send messages
|
|
449
|
+
|
|
450
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
451
|
+
// ACTIONS
|
|
452
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
453
|
+
|
|
454
|
+
store.toggleDrawer() // Toggle drawer visibility
|
|
455
|
+
store.openDrawer() // Open drawer
|
|
456
|
+
store.closeDrawer() // Close drawer
|
|
457
|
+
store.askQuestion(question, attachments?, mentions?, isSupportRequest?)
|
|
458
|
+
store.cancelRequest() // Cancel current request
|
|
459
|
+
store.retry() // Retry failed message
|
|
460
|
+
store.clearChatHistory() // Clear all messages
|
|
461
|
+
store.clearError() // Clear error state
|
|
462
|
+
store.toggleSupportMode() // Toggle support mode
|
|
463
|
+
store.fetchQuota() // Fetch quota from server
|
|
464
|
+
store.uploadFile(file) // Upload file
|
|
465
|
+
store.setPageContext(context) // Set page context
|
|
466
|
+
store.scrollToBottom() // Scroll chat to bottom
|
|
363
467
|
```
|
|
364
468
|
|
|
365
|
-
## Composables
|
|
469
|
+
## ๐ช Composables
|
|
366
470
|
|
|
367
471
|
### useAiDrawerShortcut
|
|
368
472
|
|
|
473
|
+
Toggle drawer with keyboard shortcut:
|
|
474
|
+
|
|
369
475
|
```typescript
|
|
370
|
-
import { useAiDrawerShortcut } from
|
|
476
|
+
import { useAiDrawerShortcut } from '@doderasoftware/restify-ai'
|
|
371
477
|
|
|
372
|
-
// Uses store directly
|
|
478
|
+
// Uses store's showChat state directly
|
|
373
479
|
useAiDrawerShortcut()
|
|
374
|
-
|
|
375
|
-
// Or pass a ref
|
|
376
|
-
const drawerRef = ref(false)
|
|
377
|
-
useAiDrawerShortcut(drawerRef)
|
|
378
480
|
```
|
|
379
481
|
|
|
380
482
|
### usePageAiContext
|
|
381
483
|
|
|
484
|
+
Set page context for AI suggestions:
|
|
485
|
+
|
|
382
486
|
```typescript
|
|
383
|
-
import { usePageAiContext } from
|
|
487
|
+
import { usePageAiContext } from '@doderasoftware/restify-ai'
|
|
384
488
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
489
|
+
// Simple usage
|
|
490
|
+
usePageAiContext('invoices')
|
|
491
|
+
|
|
492
|
+
// With dynamic metadata
|
|
493
|
+
usePageAiContext('employee-detail', {
|
|
494
|
+
employeeId: computed(() => route.params.id),
|
|
495
|
+
employeeName: computed(() => employee.value?.name),
|
|
390
496
|
})
|
|
391
497
|
```
|
|
392
498
|
|
|
393
|
-
|
|
499
|
+
### useAiContext
|
|
394
500
|
|
|
395
|
-
|
|
501
|
+
Programmatic context control:
|
|
396
502
|
|
|
397
|
-
|
|
503
|
+
```typescript
|
|
504
|
+
import { useAiContext } from '@doderasoftware/restify-ai'
|
|
398
505
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
506
|
+
const { setContext, updateContext, clearContext, context } = useAiContext()
|
|
507
|
+
|
|
508
|
+
setContext({
|
|
509
|
+
pageType: 'dashboard',
|
|
510
|
+
entityType: 'report',
|
|
511
|
+
entityId: '123',
|
|
512
|
+
metadata: { period: 'Q4' }
|
|
513
|
+
})
|
|
406
514
|
```
|
|
407
515
|
|
|
408
|
-
###
|
|
516
|
+
### useAiSuggestions
|
|
409
517
|
|
|
410
|
-
|
|
518
|
+
Get suggestions for current context:
|
|
411
519
|
|
|
412
520
|
```typescript
|
|
413
|
-
|
|
414
|
-
{
|
|
415
|
-
question: string
|
|
416
|
-
history: Array<{ role: string, message: string }>
|
|
417
|
-
stream: true
|
|
418
|
-
files?: Array<{ id: string, name: string }>
|
|
419
|
-
mentions?: Array<{ id: string, type: string, name: string }>
|
|
420
|
-
}
|
|
521
|
+
import { useAiSuggestions } from '@doderasoftware/restify-ai'
|
|
421
522
|
|
|
422
|
-
|
|
423
|
-
data: {"choices":[{"delta":{"content":"Hello"}}]}
|
|
424
|
-
data: {"choices":[{"delta":{"content":" world"}}]}
|
|
425
|
-
data: [DONE]
|
|
523
|
+
const { suggestions, resolvePrompt } = useAiSuggestions()
|
|
426
524
|
```
|
|
427
525
|
|
|
428
|
-
|
|
526
|
+
## ๐ท๏ธ Mention Providers
|
|
527
|
+
|
|
528
|
+
Enable @mentions to reference entities from your application:
|
|
429
529
|
|
|
430
530
|
```typescript
|
|
431
|
-
|
|
531
|
+
import type { MentionProvider, MentionItem } from '@doderasoftware/restify-ai'
|
|
532
|
+
|
|
533
|
+
function createMentionProviders(): MentionProvider[] {
|
|
534
|
+
return [
|
|
535
|
+
{
|
|
536
|
+
type: 'employee',
|
|
537
|
+
label: 'Team Members',
|
|
538
|
+
iconClass: 'text-primary',
|
|
539
|
+
priority: 10,
|
|
540
|
+
|
|
541
|
+
// Search function - can be sync or async
|
|
542
|
+
search: (query: string): MentionItem[] => {
|
|
543
|
+
const employeeStore = useEmployeeStore()
|
|
544
|
+
const employees = employeeStore.allEmployees || []
|
|
545
|
+
|
|
546
|
+
if (!query) {
|
|
547
|
+
return employees.slice(0, 5).map(emp => ({
|
|
548
|
+
id: emp.id,
|
|
549
|
+
type: 'employee',
|
|
550
|
+
attributes: emp.attributes,
|
|
551
|
+
}))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const lowerQuery = query.toLowerCase()
|
|
555
|
+
return employees
|
|
556
|
+
.filter((emp) => {
|
|
557
|
+
const firstName = emp.attributes?.first_name?.toLowerCase() || ''
|
|
558
|
+
const lastName = emp.attributes?.last_name?.toLowerCase() || ''
|
|
559
|
+
return firstName.includes(lowerQuery) || lastName.includes(lowerQuery)
|
|
560
|
+
})
|
|
561
|
+
.slice(0, 5)
|
|
562
|
+
.map(emp => ({
|
|
563
|
+
id: emp.id,
|
|
564
|
+
type: 'employee',
|
|
565
|
+
attributes: emp.attributes,
|
|
566
|
+
}))
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// Display formatting
|
|
570
|
+
getDisplayName: (item: MentionItem): string => {
|
|
571
|
+
const firstName = item.attributes?.first_name || ''
|
|
572
|
+
const lastName = item.attributes?.last_name || ''
|
|
573
|
+
return `${firstName} ${lastName}`.trim()
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
getSubtitle: (item: MentionItem): string | null => {
|
|
577
|
+
return item.attributes?.position || null
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
buildMentionText: (item: MentionItem): string => {
|
|
581
|
+
const firstName = item.attributes?.first_name || ''
|
|
582
|
+
const lastName = item.attributes?.last_name || ''
|
|
583
|
+
return `@${firstName} ${lastName}`.trim()
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
type: 'job',
|
|
588
|
+
label: 'Jobs',
|
|
589
|
+
iconClass: 'text-blue-500',
|
|
590
|
+
routes: ['/hiring/jobs'], // Only show on these routes
|
|
591
|
+
priority: 5,
|
|
592
|
+
search: async (query: string): Promise<MentionItem[]> => {
|
|
593
|
+
const response = await api.get('/jobs', { params: { search: query }})
|
|
594
|
+
return response.data.map(job => ({
|
|
595
|
+
id: job.id,
|
|
596
|
+
type: 'job',
|
|
597
|
+
attributes: job,
|
|
598
|
+
}))
|
|
599
|
+
},
|
|
600
|
+
getDisplayName: (item) => item.attributes?.title || 'Untitled',
|
|
601
|
+
getSubtitle: (item) => item.attributes?.location || null,
|
|
602
|
+
},
|
|
603
|
+
]
|
|
604
|
+
}
|
|
432
605
|
```
|
|
433
606
|
|
|
434
|
-
|
|
607
|
+
### MentionProvider Interface
|
|
435
608
|
|
|
436
609
|
```typescript
|
|
437
|
-
|
|
610
|
+
interface MentionProvider {
|
|
611
|
+
type: string // Unique type identifier
|
|
612
|
+
label: string // Display label for group
|
|
613
|
+
icon?: Component // Icon component
|
|
614
|
+
iconClass?: string // Icon CSS classes
|
|
615
|
+
routes?: string[] // Limit to specific routes
|
|
616
|
+
priority?: number // Sort order (higher = first)
|
|
617
|
+
search: (query: string) => Promise<MentionItem[]> | MentionItem[]
|
|
618
|
+
getDisplayName?: (item: MentionItem) => string
|
|
619
|
+
getSubtitle?: (item: MentionItem) => string | null
|
|
620
|
+
buildMentionText?: (item: MentionItem) => string
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
interface MentionItem {
|
|
624
|
+
id: string
|
|
625
|
+
type: string
|
|
626
|
+
name?: string
|
|
627
|
+
label?: string
|
|
628
|
+
title?: string
|
|
629
|
+
attributes?: Record<string, any> | null
|
|
630
|
+
relationships?: Record<string, any> | null
|
|
631
|
+
}
|
|
438
632
|
```
|
|
439
633
|
|
|
440
|
-
##
|
|
634
|
+
## ๐ก Suggestion Providers
|
|
441
635
|
|
|
442
|
-
|
|
636
|
+
Context-aware suggestions based on current page:
|
|
443
637
|
|
|
444
638
|
```typescript
|
|
445
|
-
import type {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
639
|
+
import type { SuggestionProvider, AISuggestion, PageContext } from '@doderasoftware/restify-ai'
|
|
640
|
+
import { UserGroupIcon, ChartBarIcon, CalendarDaysIcon } from '@heroicons/vue/24/outline'
|
|
641
|
+
|
|
642
|
+
function createSuggestionProviders(): SuggestionProvider[] {
|
|
643
|
+
return [
|
|
644
|
+
{
|
|
645
|
+
id: 'employees',
|
|
646
|
+
routes: ['/employees'],
|
|
647
|
+
priority: 10,
|
|
648
|
+
|
|
649
|
+
getSuggestions: (context: PageContext): AISuggestion[] => {
|
|
650
|
+
const isDetailView = context.entityId
|
|
651
|
+
const employeeName = context.metadata?.employeeName
|
|
652
|
+
|
|
653
|
+
if (isDetailView && employeeName) {
|
|
654
|
+
// Suggestions for employee detail page
|
|
655
|
+
return [
|
|
656
|
+
{
|
|
657
|
+
id: 'performance-review',
|
|
658
|
+
title: 'Prepare Evaluation',
|
|
659
|
+
description: 'Performance review points',
|
|
660
|
+
icon: ChartBarIcon,
|
|
661
|
+
gradientClass: 'bg-gradient-to-br from-violet-500/10 to-purple-500/10',
|
|
662
|
+
prompt: `Help me prepare a performance review for ${employeeName}.`,
|
|
663
|
+
permission: 'manageEmployees',
|
|
664
|
+
},
|
|
665
|
+
]
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Suggestions for employee list
|
|
669
|
+
return [
|
|
670
|
+
{
|
|
671
|
+
id: 'find-available',
|
|
672
|
+
title: 'Who is Available?',
|
|
673
|
+
description: 'See who is not on leave',
|
|
674
|
+
icon: CalendarDaysIcon,
|
|
675
|
+
gradientClass: 'bg-gradient-to-br from-teal-500/10 to-emerald-500/10',
|
|
676
|
+
prompt: 'Who is available today and not on leave?',
|
|
677
|
+
permission: 'manageEmployees',
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
id: 'team-analytics',
|
|
681
|
+
title: 'Team Analysis',
|
|
682
|
+
description: 'Team structure insights',
|
|
683
|
+
icon: ChartBarIcon,
|
|
684
|
+
gradientClass: 'bg-gradient-to-br from-amber-500/10 to-orange-500/10',
|
|
685
|
+
prompt: 'Give me an overview of our team structure.',
|
|
686
|
+
permission: 'manageEmployees',
|
|
687
|
+
},
|
|
688
|
+
]
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
id: 'hiring',
|
|
693
|
+
routes: ['/hiring/jobs', '/hiring/candidates'],
|
|
694
|
+
priority: 10,
|
|
695
|
+
getSuggestions: (): AISuggestion[] => [
|
|
696
|
+
{
|
|
697
|
+
id: 'open-positions',
|
|
698
|
+
title: 'Open Positions',
|
|
699
|
+
description: 'List all open job positions',
|
|
700
|
+
icon: UserGroupIcon,
|
|
701
|
+
prompt: 'Show me all currently open job positions.',
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
]
|
|
706
|
+
}
|
|
457
707
|
```
|
|
458
708
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
- Chrome 80+
|
|
462
|
-
- Firefox 75+
|
|
463
|
-
- Safari 13+
|
|
464
|
-
- Edge 80+
|
|
709
|
+
### Default Suggestions
|
|
465
710
|
|
|
466
|
-
|
|
711
|
+
```typescript
|
|
712
|
+
function getDefaultSuggestions(): AISuggestion[] {
|
|
713
|
+
return [
|
|
714
|
+
{
|
|
715
|
+
id: 'help',
|
|
716
|
+
title: 'How can you help?',
|
|
717
|
+
description: 'Learn what I can do',
|
|
718
|
+
prompt: 'What can you help me with?',
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
id: 'contact-support',
|
|
722
|
+
title: 'Contact Support',
|
|
723
|
+
description: 'Get help from a human',
|
|
724
|
+
prompt: 'I need to contact support',
|
|
725
|
+
isSupportRequest: true,
|
|
726
|
+
},
|
|
727
|
+
]
|
|
728
|
+
}
|
|
729
|
+
```
|
|
467
730
|
|
|
468
|
-
|
|
731
|
+
### SuggestionProvider Interface
|
|
469
732
|
|
|
470
|
-
|
|
733
|
+
```typescript
|
|
734
|
+
interface SuggestionProvider {
|
|
735
|
+
id: string // Unique identifier
|
|
736
|
+
routes?: string[] // Route patterns to match
|
|
737
|
+
matcher?: (path: string, context: PageContext | null) => boolean
|
|
738
|
+
getSuggestions: (context: PageContext) => AISuggestion[]
|
|
739
|
+
extractContext?: (path: string) => Record<string, any>
|
|
740
|
+
priority?: number // Higher = first
|
|
741
|
+
}
|
|
471
742
|
|
|
472
|
-
|
|
743
|
+
interface AISuggestion {
|
|
744
|
+
id: string
|
|
745
|
+
title: string
|
|
746
|
+
description?: string
|
|
747
|
+
icon?: Component
|
|
748
|
+
className?: string
|
|
749
|
+
gradientClass?: string
|
|
750
|
+
prompt: string | ((context: PageContext) => string)
|
|
751
|
+
permission?: string
|
|
752
|
+
category?: string
|
|
753
|
+
isSupportRequest?: boolean
|
|
754
|
+
}
|
|
755
|
+
```
|
|
473
756
|
|
|
474
|
-
|
|
757
|
+
## ๐จ UI Customization
|
|
475
758
|
|
|
476
|
-
|
|
477
|
-
<p><strong>Built with love by <a href="https://binarcode.com">BinarCode</a></strong></p>
|
|
478
|
-
<p>
|
|
479
|
-
<a href="https://laravel-restify.com">Laravel Restify</a> |
|
|
480
|
-
<a href="https://github.com/BinarCode/laravel-restify">GitHub</a> |
|
|
481
|
-
<a href="https://binarcode.com">Website</a>
|
|
482
|
-
</p>
|
|
483
|
-
<p><sub>Published by <a href="https://doderasoft.com">Dodera Software</a></sub></p>
|
|
484
|
-
</div>
|
|
759
|
+
Override CSS classes for any component:
|
|
485
760
|
|
|
486
|
-
|
|
761
|
+
```vue
|
|
762
|
+
<AiChatDrawer
|
|
763
|
+
v-model="isOpen"
|
|
764
|
+
:ui="{
|
|
765
|
+
backdrop: 'bg-black/50 backdrop-blur-sm',
|
|
766
|
+
drawer: 'shadow-2xl',
|
|
767
|
+
panel: 'bg-gray-50 dark:bg-gray-900',
|
|
768
|
+
header: 'border-b-2 border-primary-500',
|
|
769
|
+
body: 'custom-scrollbar',
|
|
770
|
+
footer: 'border-t border-gray-200',
|
|
771
|
+
}"
|
|
772
|
+
/>
|
|
773
|
+
```
|
|
487
774
|
|
|
488
|
-
|
|
775
|
+
### AiChatDrawerUI
|
|
489
776
|
|
|
490
777
|
```typescript
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
778
|
+
interface AiChatDrawerUI {
|
|
779
|
+
backdrop?: string // Backdrop overlay
|
|
780
|
+
drawer?: string // Main drawer container
|
|
781
|
+
panel?: string // Inner panel
|
|
782
|
+
header?: string // Header container
|
|
783
|
+
headerTitle?: string // Header title
|
|
784
|
+
headerActions?: string // Header actions
|
|
785
|
+
headerActionButton?: string // Header buttons
|
|
786
|
+
body?: string // Messages container
|
|
787
|
+
footer?: string // Footer container
|
|
788
|
+
quotaDisplay?: string // Quota display
|
|
789
|
+
newChatButton?: string // New chat button
|
|
790
|
+
errorContainer?: string // Error container
|
|
791
|
+
errorMessage?: string // Error message
|
|
792
|
+
retryButton?: string // Retry button
|
|
793
|
+
closeConfirmModal?: string // Confirm modal
|
|
794
|
+
closeConfirmButton?: string // Confirm button
|
|
795
|
+
cancelButton?: string // Cancel button
|
|
796
|
+
historyLimitModal?: string // History limit modal
|
|
797
|
+
historyLimitButton?: string // History limit button
|
|
798
|
+
}
|
|
510
799
|
```
|
|
511
800
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
### useAiDrawerShortcut
|
|
801
|
+
### ChatInputUI
|
|
515
802
|
|
|
516
803
|
```typescript
|
|
517
|
-
|
|
804
|
+
interface ChatInputUI {
|
|
805
|
+
root?: string // Root container
|
|
806
|
+
form?: string // Form wrapper
|
|
807
|
+
inputContainer?: string // Input container
|
|
808
|
+
inputWrapper?: string // Input border wrapper
|
|
809
|
+
textarea?: string // Textarea element
|
|
810
|
+
attachButton?: string // Attach button
|
|
811
|
+
sendButton?: string // Send button
|
|
812
|
+
sendButtonActive?: string // Send active state
|
|
813
|
+
sendButtonDisabled?: string // Send disabled state
|
|
814
|
+
stopButton?: string // Stop button
|
|
815
|
+
supportToggle?: string // Support toggle
|
|
816
|
+
supportBadge?: string // Support badge
|
|
817
|
+
attachmentsContainer?: string // Attachments container
|
|
818
|
+
attachmentItem?: string // Attachment item
|
|
819
|
+
attachmentThumbnail?: string // Attachment thumbnail
|
|
820
|
+
attachmentRemove?: string // Remove button
|
|
821
|
+
suggestionsDropdown?: string // Suggestions dropdown
|
|
822
|
+
suggestionItem?: string // Suggestion item
|
|
823
|
+
suggestionItemSelected?: string // Selected suggestion
|
|
824
|
+
contextLink?: string // Context link
|
|
825
|
+
}
|
|
826
|
+
```
|
|
518
827
|
|
|
519
|
-
|
|
520
|
-
useAiDrawerShortcut()
|
|
828
|
+
### ChatMessageUI
|
|
521
829
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
830
|
+
```typescript
|
|
831
|
+
interface ChatMessageUI {
|
|
832
|
+
root?: string // Root container
|
|
833
|
+
userMessage?: string // User message container
|
|
834
|
+
userBubble?: string // User bubble
|
|
835
|
+
userAvatar?: string // User avatar
|
|
836
|
+
assistantMessage?: string // Assistant container
|
|
837
|
+
assistantBubble?: string // Assistant bubble
|
|
838
|
+
loadingIndicator?: string // Loading indicator
|
|
839
|
+
loadingDots?: string // Loading dots
|
|
840
|
+
content?: string // Content wrapper
|
|
841
|
+
attachmentsContainer?: string // Attachments
|
|
842
|
+
attachmentItem?: string // Attachment item
|
|
843
|
+
actionsContainer?: string // Actions container
|
|
844
|
+
showMoreButton?: string // Show more button
|
|
845
|
+
}
|
|
525
846
|
```
|
|
526
847
|
|
|
527
|
-
|
|
848
|
+
## ๐ TypeScript Types
|
|
528
849
|
|
|
529
|
-
|
|
530
|
-
import { usePageAiContext } from "@doderasoftware/restify-ai"
|
|
850
|
+
All types are exported:
|
|
531
851
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
852
|
+
```typescript
|
|
853
|
+
import type {
|
|
854
|
+
// Core Config
|
|
855
|
+
RestifyAiConfig,
|
|
856
|
+
RestifyAiEndpoints,
|
|
857
|
+
RestifyAiLabels,
|
|
858
|
+
RestifyAiTheme,
|
|
859
|
+
|
|
860
|
+
// Chat Types
|
|
861
|
+
ChatMessage,
|
|
862
|
+
ChatQuota,
|
|
863
|
+
ChatError,
|
|
864
|
+
ChatAttachment,
|
|
865
|
+
ChatRole,
|
|
866
|
+
SubmitPayload,
|
|
867
|
+
|
|
868
|
+
// Context
|
|
869
|
+
PageContext,
|
|
870
|
+
|
|
871
|
+
// Providers
|
|
872
|
+
MentionProvider,
|
|
873
|
+
MentionItem,
|
|
874
|
+
Mention,
|
|
875
|
+
SuggestionProvider,
|
|
876
|
+
AISuggestion,
|
|
877
|
+
|
|
878
|
+
// History
|
|
879
|
+
HistoryLimitConfig,
|
|
880
|
+
LoadingTextConfig,
|
|
881
|
+
|
|
882
|
+
// UI Customization
|
|
883
|
+
AiChatDrawerUI,
|
|
884
|
+
ChatInputUI,
|
|
885
|
+
ChatMessageUI,
|
|
886
|
+
AiEmptyStateUI,
|
|
887
|
+
MentionListUI,
|
|
888
|
+
|
|
889
|
+
// Text Customization
|
|
890
|
+
AiChatDrawerTexts,
|
|
891
|
+
ChatInputTexts,
|
|
892
|
+
ChatMessageTexts,
|
|
893
|
+
|
|
894
|
+
// Slot Props
|
|
895
|
+
HeaderSlotProps,
|
|
896
|
+
MessageSlotProps,
|
|
897
|
+
InputSlotProps,
|
|
898
|
+
EmptyStateSlotProps,
|
|
899
|
+
|
|
900
|
+
// Hooks
|
|
901
|
+
BeforeSendHook,
|
|
902
|
+
AfterResponseHook,
|
|
903
|
+
OnStreamStartHook,
|
|
904
|
+
OnStreamEndHook,
|
|
905
|
+
OnStreamChunkHook,
|
|
906
|
+
StreamParserFunction,
|
|
907
|
+
RetryConfig,
|
|
908
|
+
} from '@doderasoftware/restify-ai'
|
|
538
909
|
```
|
|
539
910
|
|
|
540
|
-
## Backend Integration
|
|
911
|
+
## ๐ Backend Integration
|
|
541
912
|
|
|
542
|
-
|
|
913
|
+
This package is designed for [Laravel Restify](https://laravel-restify.com) backends:
|
|
543
914
|
|
|
544
|
-
|
|
915
|
+
### Ask Endpoint (SSE Stream)
|
|
545
916
|
|
|
546
917
|
```php
|
|
547
918
|
// routes/api.php
|
|
548
|
-
Route::
|
|
549
|
-
Route::post("/ai/ask", [AiController::class, "ask"]);
|
|
550
|
-
Route::post("/ai/upload", [AiController::class, "upload"]);
|
|
551
|
-
Route::get("/ai/quota", [AiController::class, "quota"]);
|
|
552
|
-
});
|
|
919
|
+
Route::post('/ask', [AiController::class, 'ask']);
|
|
553
920
|
```
|
|
554
921
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
**Ask Endpoint (SSE Stream):**
|
|
558
|
-
|
|
559
|
-
```typescript
|
|
560
|
-
// Request
|
|
922
|
+
**Request:**
|
|
923
|
+
```json
|
|
561
924
|
{
|
|
562
|
-
question:
|
|
563
|
-
history:
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
925
|
+
"question": "Who is available today?",
|
|
926
|
+
"history": [
|
|
927
|
+
{ "role": "user", "message": "Hello" },
|
|
928
|
+
{ "role": "assistant", "message": "Hi! How can I help?" }
|
|
929
|
+
],
|
|
930
|
+
"stream": true,
|
|
931
|
+
"files": [{ "id": "file-123", "name": "report.pdf" }],
|
|
932
|
+
"mentions": [{ "id": "emp-1", "type": "employee", "name": "John Doe" }],
|
|
933
|
+
"contact_support": false
|
|
567
934
|
}
|
|
935
|
+
```
|
|
568
936
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
data: {"choices":[{"delta":{"content":"
|
|
937
|
+
**Response (SSE):**
|
|
938
|
+
```
|
|
939
|
+
data: {"choices":[{"delta":{"content":"Based on"}}]}
|
|
940
|
+
data: {"choices":[{"delta":{"content":" the schedule..."}}]}
|
|
572
941
|
data: [DONE]
|
|
573
942
|
```
|
|
574
943
|
|
|
575
|
-
|
|
944
|
+
### Quota Endpoint
|
|
576
945
|
|
|
577
|
-
```
|
|
578
|
-
|
|
946
|
+
```php
|
|
947
|
+
// routes/api.php
|
|
948
|
+
Route::get('/ai/quota', [AiController::class, 'quota']);
|
|
579
949
|
```
|
|
580
950
|
|
|
581
|
-
**
|
|
951
|
+
**Response:**
|
|
952
|
+
```json
|
|
953
|
+
{
|
|
954
|
+
"data": {
|
|
955
|
+
"limit": 100,
|
|
956
|
+
"used": 25,
|
|
957
|
+
"remaining": 75
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
```
|
|
582
961
|
|
|
583
|
-
|
|
584
|
-
|
|
962
|
+
### Upload Endpoint
|
|
963
|
+
|
|
964
|
+
```php
|
|
965
|
+
// routes/api.php
|
|
966
|
+
Route::post('/ai/upload', [AiController::class, 'upload']);
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
**Response:**
|
|
970
|
+
```json
|
|
971
|
+
{
|
|
972
|
+
"data": {
|
|
973
|
+
"id": "file-123",
|
|
974
|
+
"name": "document.pdf",
|
|
975
|
+
"url": "/storage/uploads/document.pdf",
|
|
976
|
+
"type": "application/pdf",
|
|
977
|
+
"size": 102400
|
|
978
|
+
}
|
|
979
|
+
}
|
|
585
980
|
```
|
|
586
981
|
|
|
587
|
-
##
|
|
982
|
+
## โจ๏ธ Keyboard Shortcuts
|
|
588
983
|
|
|
589
|
-
|
|
984
|
+
| Shortcut | Action |
|
|
985
|
+
|----------|--------|
|
|
986
|
+
| `โ/Ctrl + G` | Toggle drawer (configurable) |
|
|
987
|
+
| `Escape` | Close drawer |
|
|
988
|
+
| `Enter` | Send message |
|
|
989
|
+
| `Shift + Enter` | New line |
|
|
990
|
+
|
|
991
|
+
## ๐พ Session Storage
|
|
992
|
+
|
|
993
|
+
Chat history persists in `sessionStorage` by default:
|
|
994
|
+
|
|
995
|
+
- Key: `restify_ai_chat_history` (configurable via `chatHistoryKey`)
|
|
996
|
+
- Cleared on browser close
|
|
997
|
+
- Persists across page navigation
|
|
998
|
+
|
|
999
|
+
## ๐ฆ Package Exports
|
|
590
1000
|
|
|
591
1001
|
```typescript
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1002
|
+
// Components
|
|
1003
|
+
export { AiChatDrawer } from './components'
|
|
1004
|
+
|
|
1005
|
+
// Store
|
|
1006
|
+
export { useRestifyAiStore } from './store'
|
|
1007
|
+
|
|
1008
|
+
// Composables
|
|
1009
|
+
export {
|
|
1010
|
+
useAiDrawerShortcut,
|
|
1011
|
+
usePageAiContext,
|
|
1012
|
+
useAiContext,
|
|
1013
|
+
useAiSuggestions
|
|
1014
|
+
} from './composables'
|
|
1015
|
+
|
|
1016
|
+
// Config
|
|
1017
|
+
export {
|
|
1018
|
+
initRestifyAi,
|
|
1019
|
+
getConfig,
|
|
1020
|
+
getLabels,
|
|
1021
|
+
getUI,
|
|
1022
|
+
RestifyAiPlugin
|
|
1023
|
+
} from './config'
|
|
1024
|
+
|
|
1025
|
+
// Types
|
|
1026
|
+
export * from './types'
|
|
604
1027
|
```
|
|
605
1028
|
|
|
606
|
-
##
|
|
1029
|
+
## ๐ค Requirements
|
|
607
1030
|
|
|
608
|
-
-
|
|
609
|
-
-
|
|
610
|
-
-
|
|
611
|
-
- Edge 80+
|
|
1031
|
+
- **Vue 3.3+**
|
|
1032
|
+
- **Pinia 2.1+**
|
|
1033
|
+
- A backend implementing the streaming chat API (e.g., [Laravel Restify](https://laravel-restify.com))
|
|
612
1034
|
|
|
613
|
-
##
|
|
1035
|
+
## ๐ Links
|
|
614
1036
|
|
|
615
|
-
|
|
1037
|
+
- [๐ Laravel Restify Documentation](https://laravel-restify.com)
|
|
1038
|
+
- [๐ฆ npm Package](https://www.npmjs.com/package/@doderasoftware/restify-ai)
|
|
1039
|
+
- [๐ข BinarCode](https://binarcode.com)
|
|
1040
|
+
- [๐ป GitHub](https://github.com/BinarCode/laravel-restify)
|
|
616
1041
|
|
|
617
|
-
## License
|
|
1042
|
+
## ๐ License
|
|
618
1043
|
|
|
619
|
-
|
|
1044
|
+
MIT ยฉ [BinarCode](https://binarcode.com)
|
|
620
1045
|
|
|
621
1046
|
---
|
|
622
1047
|
|
|
623
|
-
|
|
624
|
-
<p><strong>Built with love by <a href="https://binarcode.com">BinarCode</a></strong></p>
|
|
625
|
-
<p>
|
|
626
|
-
<a href="https://laravel-restify.com">Laravel Restify</a> |
|
|
627
|
-
<a href="https://github.com/BinarCode/laravel-restify">GitHub</a> |
|
|
628
|
-
<a href="https://binarcode.com">Website</a>
|
|
629
|
-
</p>
|
|
630
|
-
<p><sub>Published by <a href="https://doderasoft.com">Dodera Software</a></sub></p>
|
|
631
|
-
</div>
|
|
1048
|
+
Built with โค๏ธ by the [BinarCode](https://binarcode.com) team ยท Published by [Dodera Software](https://doderasoft.com)
|