Skip to content

Commit 1923cd4

Browse files
committed
feat(AI): chat suggestions and assistant settings
1 parent 029c217 commit 1923cd4

File tree

24 files changed

+460
-43
lines changed

24 files changed

+460
-43
lines changed

admin/app/controllers/chats_controller.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@ import { inject } from '@adonisjs/core'
22
import type { HttpContext } from '@adonisjs/core/http'
33
import { ChatService } from '#services/chat_service'
44
import { createSessionSchema, updateSessionSchema, addMessageSchema } from '#validators/chat'
5+
import { parseBoolean } from '../utils/misc.js'
6+
import KVStore from '#models/kv_store'
57

68
@inject()
79
export default class ChatsController {
810
constructor(private chatService: ChatService) {}
911

12+
async inertia({ inertia }: HttpContext) {
13+
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
14+
return inertia.render('chat', {
15+
settings: {
16+
chatSuggestionsEnabled: parseBoolean(chatSuggestionsEnabled),
17+
},
18+
})
19+
}
20+
1021
async index({}: HttpContext) {
1122
return await this.chatService.getAllSessions()
1223
}
@@ -34,6 +45,17 @@ export default class ChatsController {
3445
}
3546
}
3647

48+
async suggestions({ response }: HttpContext) {
49+
try {
50+
const suggestions = await this.chatService.getChatSuggestions()
51+
return response.status(200).json({ suggestions })
52+
} catch (error) {
53+
return response.status(500).json({
54+
error: error instanceof Error ? error.message : 'Failed to get suggestions',
55+
})
56+
}
57+
}
58+
3759
async update({ params, request, response }: HttpContext) {
3860
try {
3961
const sessionId = parseInt(params.id)

admin/app/controllers/settings_controller.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import KVStore from '#models/kv_store';
12
import { BenchmarkService } from '#services/benchmark_service';
23
import { MapService } from '#services/map_service';
34
import { OllamaService } from '#services/ollama_service';
45
import { SystemService } from '#services/system_service';
6+
import { updateSettingSchema } from '#validators/settings';
57
import { inject } from '@adonisjs/core';
68
import type { HttpContext } from '@adonisjs/core/http'
9+
import { parseBoolean } from '../utils/misc.js';
710

811
@inject()
912
export default class SettingsController {
@@ -50,10 +53,14 @@ export default class SettingsController {
5053
async models({ inertia }: HttpContext) {
5154
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false });
5255
const installedModels = await this.ollamaService.getModels();
56+
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
5357
return inertia.render('settings/models', {
5458
models: {
5559
availableModels: availableModels || [],
56-
installedModels: installedModels || []
60+
installedModels: installedModels || [],
61+
settings: {
62+
chatSuggestionsEnabled: parseBoolean(chatSuggestionsEnabled)
63+
}
5764
}
5865
});
5966
}
@@ -88,4 +95,16 @@ export default class SettingsController {
8895
}
8996
});
9097
}
98+
99+
async getSetting({ request, response }: HttpContext) {
100+
const key = request.qs().key;
101+
const value = await KVStore.getValue(key);
102+
return response.status(200).send({ key, value });
103+
}
104+
105+
async updateSetting({ request, response }: HttpContext) {
106+
const reqData = await request.validateUsing(updateSettingSchema);
107+
await this.systemService.updateSetting(reqData.key, reqData.value);
108+
return response.status(200).send({ success: true, message: 'Setting updated successfully' });
109+
}
91110
}

admin/app/models/kv_store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { KVStoreKey, KVStoreValue } from '../../types/kv_store.js'
77
* that don't necessitate their own dedicated models.
88
*/
99
export default class KVStore extends BaseModel {
10+
static table = 'kv_store'
1011
static namingStrategy = new SnakeCaseNamingStrategy()
1112

1213
@column({ isPrimary: true })
@@ -29,7 +30,13 @@ export default class KVStore extends BaseModel {
2930
*/
3031
static async getValue(key: KVStoreKey): Promise<KVStoreValue> {
3132
const setting = await this.findBy('key', key)
32-
return setting?.value ?? null
33+
if (!setting || setting.value === undefined || setting.value === null) {
34+
return null
35+
}
36+
if (typeof setting.value === 'string') {
37+
return setting.value
38+
}
39+
return String(setting.value)
3340
}
3441

3542
/**

admin/app/services/chat_service.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { DateTime } from 'luxon'
55
import { inject } from '@adonisjs/core'
66
import { OllamaService } from './ollama_service.js'
77
import { ChatRequest } from 'ollama'
8+
import { SYSTEM_PROMPTS } from '../../constants/ollama.js'
9+
import { toTitleCase } from '../utils/misc.js'
810

911
@inject()
1012
export class ChatService {
@@ -37,9 +39,75 @@ export class ChatService {
3739
}
3840
}
3941

42+
async getChatSuggestions() {
43+
try {
44+
const models = await this.ollamaService.getModels()
45+
if (!models) {
46+
return [] // If no models are available, return empty suggestions
47+
}
48+
49+
// Larger models generally give "better" responses, so pick the largest one
50+
const largestModel = models.reduce((prev, current) => {
51+
return prev.size > current.size ? prev : current
52+
})
53+
54+
if (!largestModel) {
55+
return []
56+
}
57+
58+
const response = await this.ollamaService.chat({
59+
model: largestModel.name,
60+
messages: [
61+
{
62+
role: 'user',
63+
content: SYSTEM_PROMPTS.chat_suggestions,
64+
}
65+
],
66+
stream: false,
67+
})
68+
69+
if (response && response.message && response.message.content) {
70+
const content = response.message.content.trim()
71+
72+
// Handle both comma-separated and newline-separated formats
73+
let suggestions: string[] = []
74+
75+
// Try splitting by commas first
76+
if (content.includes(',')) {
77+
suggestions = content.split(',').map((s) => s.trim())
78+
}
79+
// Fall back to newline separation
80+
else {
81+
suggestions = content
82+
.split(/\r?\n/)
83+
.map((s) => s.trim())
84+
// Remove numbered list markers (1., 2., 3., etc.) and bullet points
85+
.map((s) => s.replace(/^\d+\.\s*/, '').replace(/^[-*]\s*/, ''))
86+
// Remove surrounding quotes if present
87+
.map((s) => s.replace(/^["']|["']$/g, ''))
88+
}
89+
90+
// Filter out empty strings and limit to 3 suggestions
91+
const filtered = suggestions
92+
.filter((s) => s.length > 0)
93+
.slice(0, 3)
94+
95+
return filtered.map((s) => toTitleCase(s))
96+
} else {
97+
return []
98+
}
99+
} catch (error) {
100+
logger.error(
101+
`[ChatService] Failed to get chat suggestions: ${
102+
error instanceof Error ? error.message : error
103+
}`
104+
)
105+
return []
106+
}
107+
}
108+
40109
async getSession(sessionId: number) {
41110
try {
42-
console.log('Fetching session with ID:', sessionId);
43111
const session = await ChatSession.query().where('id', sessionId).preload('messages').first()
44112

45113
if (!session) {

admin/app/services/system_service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import path, { join } from 'path'
1010
import { getAllFilesystems, getFile } from '../utils/fs.js'
1111
import axios from 'axios'
1212
import env from '#start/env'
13+
import KVStore from '#models/kv_store'
14+
import { KVStoreKey } from '../../types/kv_store.js'
1315

1416
@inject()
1517
export class SystemService {
@@ -254,6 +256,10 @@ export class SystemService {
254256
}
255257
}
256258

259+
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
260+
await KVStore.setValue(key, value);
261+
}
262+
257263
/**
258264
* Checks the current state of Docker containers against the database records and updates the database accordingly.
259265
* It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.

admin/app/utils/misc.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,23 @@ export function formatSpeed(bytesPerSecond: number): string {
33
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
44
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`
55
}
6+
7+
export function toTitleCase(str: string): string {
8+
return str
9+
.toLowerCase()
10+
.split(' ')
11+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
12+
.join(' ')
13+
}
14+
15+
export function parseBoolean(value: any): boolean {
16+
if (typeof value === 'boolean') return value
17+
if (typeof value === 'string') {
18+
const lower = value.toLowerCase()
19+
return lower === 'true' || lower === '1'
20+
}
21+
if (typeof value === 'number') {
22+
return value === 1
23+
}
24+
return false
25+
}

admin/app/validators/settings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import vine from "@vinejs/vine";
2+
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
3+
4+
5+
export const updateSettingSchema = vine.compile(vine.object({
6+
key: vine.enum(SETTINGS_KEYS),
7+
value: vine.any(),
8+
}))

admin/constants/kv_store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { KVStoreKey } from "../types/kv_store.js";
2+
3+
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled'];

admin/constants/ollama.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,29 @@ You have access to the following relevant information from the knowledge base. U
7272
${context}
7373
7474
If the user's question is related to this context, incorporate it into your response. Otherwise, respond normally.
75+
`,
76+
chat_suggestions: `
77+
You are a helpful assistant that generates conversation starter suggestions for a survivalist/prepper using an AI assistant.
78+
79+
Provide exactly 3 conversation starter topics as direct questions that someone would ask.
80+
These should be clear, complete questions that can start meaningful conversations.
81+
82+
Examples of good suggestions:
83+
- "How do I purify water in an emergency?"
84+
- "What are the best foods for long-term storage?"
85+
- "Help me create a 72-hour emergency kit"
86+
87+
Do NOT use:
88+
- Follow-up questions seeking clarification
89+
- Vague or incomplete suggestions
90+
- Questions that assume prior context
91+
- Statements that are not suggestions themselves, such as praise for asking the question
92+
- Direct questions or commands to the user
93+
94+
Return ONLY the 3 suggestions as a comma-separated list with no additional text, formatting, numbering, or quotation marks.
95+
The suggestions should be in title case.
96+
Ensure that your suggestions are comma-seperated with no conjunctions like "and" or "or".
97+
Do not use line breaks, new lines, or extra spacing to separate the suggestions.
98+
Format: suggestion1, suggestion2, suggestion3
7599
`,
76100
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import clsx from 'clsx'
2+
3+
interface BouncingDotsProps {
4+
text: string
5+
containerClassName?: string
6+
textClassName?: string
7+
}
8+
9+
export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {
10+
return (
11+
<div className={clsx("flex items-center justify-center gap-2", containerClassName)}>
12+
<span className={clsx("text-gray-600", textClassName)}>{text}</span>
13+
<span className="flex gap-1 mt-1">
14+
<span
15+
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
16+
style={{ animationDelay: '0ms' }}
17+
/>
18+
<span
19+
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
20+
style={{ animationDelay: '150ms' }}
21+
/>
22+
<span
23+
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
24+
style={{ animationDelay: '300ms' }}
25+
/>
26+
</span>
27+
</div>
28+
)
29+
}

0 commit comments

Comments
 (0)