Skip to content

Commit d261a96

Browse files
author
RobJellinghaus
committed
Well whaddaya know, Ollama lives!
1 parent 92b2932 commit d261a96

8 files changed

Lines changed: 369 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ async-graphql-actix-web = "3.0.38"
4040
fang = "0.10.4"
4141
futures-util = "0.3.30"
4242
jsonwebtoken = "8.1.0"
43+
ollama-rs = "0.2.0"
4344
utoipa-swagger-ui = { version="4", features=["actix-web"]}
4445
serde_json = "1"
4546
simple_logger = "5.0"

backend/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async fn main() -> std::io::Result<()> {
9090
api_scope = api_scope.service(services::file::endpoints(web::scope("/files")));
9191
api_scope = api_scope.service(create_rust_app::auth::endpoints(web::scope("/auth")));
9292
api_scope = api_scope.service(services::todo::endpoints(web::scope("/todos")));
93+
api_scope = api_scope.service(services::chat::endpoints(web::scope("/chat")));
9394

9495
#[cfg(debug_assertions)]
9596
{

backend/services/chat.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
use actix_web::{post, HttpResponse, web::{Data, Json}};
2+
use ollama_rs::{Ollama, generation::completion::request::GenerationRequest};
3+
use crate::models::suppliers::Suppliers;
4+
use create_rust_app::Database;
5+
6+
pub struct ChatService {
7+
ollama: Ollama,
8+
}
9+
10+
impl ChatService {
11+
pub fn new() -> Self {
12+
Self {
13+
ollama: Ollama::new("http://localhost".to_string(), 11434),
14+
}
15+
}
16+
17+
pub async fn chat_with_suppliers(
18+
&self,
19+
user_message: String,
20+
db: &Database,
21+
) -> Result<String, Box<dyn std::error::Error>> {
22+
// 1. Fetch all current suppliers
23+
let suppliers = self.get_all_suppliers(db).await?;
24+
25+
// 2. Build context with supplier data
26+
let context = self.build_supplier_context(&suppliers);
27+
28+
// 3. Create procurement expert prompt
29+
let full_prompt = self.build_prompt(&context, &user_message);
30+
31+
// 4. Send to Ollama
32+
let request = GenerationRequest::new("mistral-small3.2:24b".to_string(), full_prompt);
33+
let response = self.ollama.generate(request).await?;
34+
35+
Ok(response.response)
36+
}
37+
38+
async fn get_all_suppliers(&self, db: &Database) -> Result<Vec<Suppliers>, Box<dyn std::error::Error>> {
39+
use crate::models::suppliers::*;
40+
let mut conn = db.get_connection()?;
41+
let result = Suppliers::paginate(&mut conn, 0, 1000, SuppliersFilter::default())?;
42+
Ok(result.items)
43+
}
44+
45+
fn build_supplier_context(&self, suppliers: &[Suppliers]) -> String {
46+
if suppliers.is_empty() {
47+
return "Currently, there are no suppliers in the system.".to_string();
48+
}
49+
50+
let mut context = String::from("Current Supplier Database:\n\n");
51+
52+
for (index, supplier) in suppliers.iter().enumerate() {
53+
context.push_str(&format!(
54+
"{}. {}\n Location: {}, {}, {}, {}\n Contact: {} ({})\n Phone: {}\n Website: {}\n\n",
55+
index + 1,
56+
supplier.name,
57+
supplier.city,
58+
supplier.state,
59+
supplier.zip_code,
60+
supplier.country,
61+
supplier.contact_name,
62+
supplier.contact_email,
63+
supplier.contact_phone,
64+
supplier.website.as_deref().unwrap_or("Not provided")
65+
));
66+
}
67+
68+
context
69+
}
70+
71+
fn build_prompt(&self, supplier_context: &str, user_message: &str) -> String {
72+
format!(
73+
r#"You are a procurement expert AI assistant helping users make informed supplier decisions.
74+
Your expertise includes supplier evaluation, location-based logistics, cost optimization, and supply chain risk management.
75+
76+
CURRENT SUPPLIER DATABASE:
77+
{}
78+
79+
INSTRUCTIONS:
80+
- Help users choose the best suppliers based on their needs
81+
- Consider geographical proximity for shipping costs and delivery times
82+
- Analyze supplier locations for logistics advantages
83+
- Provide practical procurement advice
84+
- If asked about suppliers not in the database, inform the user they're not currently available
85+
- Be concise but thorough in your recommendations
86+
- Always consider location as a key factor in supplier selection
87+
88+
USER QUESTION: {}
89+
90+
RESPONSE:"#,
91+
supplier_context, user_message
92+
)
93+
}
94+
}
95+
96+
#[tsync::tsync]
97+
#[derive(serde::Deserialize)]
98+
pub struct ChatRequest {
99+
pub message: String,
100+
}
101+
102+
#[tsync::tsync]
103+
#[derive(serde::Serialize)]
104+
pub struct ChatResponse {
105+
pub response: String,
106+
}
107+
108+
#[post("")]
109+
async fn chat(
110+
db: Data<Database>,
111+
Json(request): Json<ChatRequest>,
112+
) -> HttpResponse {
113+
let chat_service = ChatService::new();
114+
115+
match chat_service.chat_with_suppliers(request.message, &db).await {
116+
Ok(response) => HttpResponse::Ok().json(ChatResponse { response }),
117+
Err(e) => {
118+
eprintln!("Chat error: {}", e);
119+
HttpResponse::InternalServerError().json(ChatResponse {
120+
response: "Sorry, I'm having trouble processing your request right now. Please try again later.".to_string()
121+
})
122+
}
123+
}
124+
}
125+
126+
pub fn endpoints(scope: actix_web::Scope) -> actix_web::Scope {
127+
scope.service(chat)
128+
}

backend/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod todo;
22
pub mod supplier;
3+
pub mod chat;
34

45
pub mod file;

frontend/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Home } from './containers/Home'
1515
import { Todos } from './containers/Todo'
1616
import { TodoGraphQLRelay } from './containers/TodoGraphQLRelay'
1717
import { SupplierList } from './containers/SupplierList'
18+
import { Chat } from './containers/Chat'
1819
import { Files } from './containers/Files'
1920
import { Route, useNavigate, Routes, useLocation } from 'react-router-dom'
2021

@@ -38,6 +39,7 @@ const App = () => {
3839
<a className="NavButton" onClick={() => navigate('/todos')}>Todos (REST)</a>
3940
<a className="NavButton" onClick={() => navigate('/todos-relay')}>Todos (Relay)</a>
4041
<a className="NavButton" onClick={() => navigate('/suppliers')}>Suppliers</a>
42+
<a className="NavButton" onClick={() => navigate('/chat')}>Chat</a>
4143
<a className="NavButton" onClick={() => navigate('/files')}>Files</a>
4244
{/* CRA: left-aligned nav buttons */}
4345
<a className="NavButton" onClick={() => navigate('/account')}>Account</a>
@@ -78,6 +80,7 @@ const App = () => {
7880
<SupplierList />
7981
</Suspense>
8082
} />
83+
<Route path="/chat" element={<Chat />} />
8184
{/* CRA: routes */}
8285
<Route path="/files" element={<Files />} />
8386
<Route path="/login" element={<LoginPage />} />

frontend/src/containers/Chat.tsx

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import React, { useState } from 'react'
2+
3+
const ChatAPI = {
4+
send: async (message: string): Promise<ChatResponse> =>
5+
await (await fetch('/api/chat', {
6+
method: 'POST',
7+
headers: {
8+
'Content-Type': 'application/json',
9+
},
10+
body: JSON.stringify({ message }),
11+
})).json(),
12+
}
13+
14+
interface ChatMessage {
15+
id: string;
16+
message: string;
17+
response: string;
18+
timestamp: Date;
19+
}
20+
21+
export const Chat = () => {
22+
const [message, setMessage] = useState<string>('')
23+
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([])
24+
const [processing, setProcessing] = useState<boolean>(false)
25+
26+
const sendMessage = async (userMessage: string) => {
27+
if (!userMessage.trim()) return
28+
29+
setProcessing(true)
30+
31+
try {
32+
const response = await ChatAPI.send(userMessage)
33+
34+
const chatMessage: ChatMessage = {
35+
id: Date.now().toString(),
36+
message: userMessage,
37+
response: response.response,
38+
timestamp: new Date()
39+
}
40+
41+
setChatHistory(prev => [...prev, chatMessage])
42+
setMessage('')
43+
} catch (error) {
44+
console.error('Chat error:', error)
45+
46+
const errorMessage: ChatMessage = {
47+
id: Date.now().toString(),
48+
message: userMessage,
49+
response: "Sorry, I'm having trouble processing your request right now. Please try again later.",
50+
timestamp: new Date()
51+
}
52+
53+
setChatHistory(prev => [...prev, errorMessage])
54+
setMessage('')
55+
}
56+
57+
setProcessing(false)
58+
}
59+
60+
const clearHistory = () => {
61+
setChatHistory([])
62+
}
63+
64+
return (
65+
<div style={{ display: 'flex', flexFlow: 'column', textAlign: 'left', height: '70vh' }}>
66+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
67+
<h1>Procurement Assistant</h1>
68+
{chatHistory.length > 0 && (
69+
<button onClick={clearHistory} style={{ marginBottom: '20px' }}>
70+
Clear History
71+
</button>
72+
)}
73+
</div>
74+
75+
<div style={{
76+
flex: 1,
77+
overflowY: 'auto',
78+
marginBottom: '20px',
79+
border: '1px solid #ddd',
80+
borderRadius: '4px',
81+
padding: '10px'
82+
}}>
83+
{chatHistory.length === 0 && (
84+
<div style={{ color: '#666', fontStyle: 'italic' }}>
85+
Welcome! I'm your procurement assistant. I can help you find the best suppliers based on your needs.
86+
Ask me about suppliers, locations, logistics, or any procurement-related questions.
87+
</div>
88+
)}
89+
90+
{chatHistory.map((chat) => (
91+
<div key={chat.id} style={{ marginBottom: '20px' }}>
92+
<div className="Form" style={{ marginBottom: '5px' }}>
93+
<div style={{ fontWeight: 'bold', color: '#0066cc' }}>
94+
You ({chat.timestamp.toLocaleTimeString()}):
95+
</div>
96+
<div style={{ marginTop: '5px' }}>
97+
{chat.message}
98+
</div>
99+
</div>
100+
101+
<div className="Form">
102+
<div style={{ fontWeight: 'bold', color: '#006600' }}>
103+
Procurement Assistant:
104+
</div>
105+
<div style={{
106+
marginTop: '5px',
107+
whiteSpace: 'pre-wrap',
108+
lineHeight: '1.4'
109+
}}>
110+
{chat.response}
111+
</div>
112+
</div>
113+
</div>
114+
))}
115+
116+
{processing && (
117+
<div className="Form" style={{ opacity: 0.7 }}>
118+
<div style={{ fontWeight: 'bold', color: '#006600' }}>
119+
Procurement Assistant:
120+
</div>
121+
<div style={{ marginTop: '5px', fontStyle: 'italic' }}>
122+
Thinking... (analyzing supplier data and generating response)
123+
</div>
124+
</div>
125+
)}
126+
</div>
127+
128+
<div className="Form" style={{
129+
padding: '20px',
130+
borderTop: '2px solid #ddd',
131+
backgroundColor: '#f9f9f9'
132+
}}>
133+
<div style={{
134+
display: 'flex',
135+
gap: '10px',
136+
alignItems: 'stretch'
137+
}}>
138+
<textarea
139+
style={{
140+
flex: 1,
141+
minHeight: '60px',
142+
padding: '12px',
143+
fontSize: '16px',
144+
borderRadius: '8px',
145+
border: '2px solid #ccc',
146+
resize: 'vertical',
147+
fontFamily: 'inherit',
148+
lineHeight: '1.4'
149+
}}
150+
placeholder="Ask about suppliers, locations, or procurement advice..."
151+
value={message}
152+
onChange={(e) => setMessage(e.target.value)}
153+
onKeyDown={(e) => {
154+
if (e.key === 'Enter' && !e.shiftKey) {
155+
e.preventDefault()
156+
sendMessage(message)
157+
}
158+
}}
159+
disabled={processing}
160+
/>
161+
<button
162+
disabled={processing || !message.trim()}
163+
style={{
164+
minHeight: '60px',
165+
minWidth: '100px',
166+
fontSize: '16px',
167+
fontWeight: 'bold',
168+
borderRadius: '8px',
169+
backgroundColor: processing || !message.trim() ? '#ccc' : '#007bff',
170+
color: 'white',
171+
border: 'none',
172+
cursor: processing || !message.trim() ? 'not-allowed' : 'pointer'
173+
}}
174+
onClick={() => sendMessage(message)}
175+
>
176+
{processing ? 'Sending...' : 'Send'}
177+
</button>
178+
</div>
179+
<div style={{
180+
fontSize: '14px',
181+
color: '#666',
182+
marginTop: '10px',
183+
textAlign: 'center'
184+
}}>
185+
Press Enter to send, Shift+Enter for new line
186+
</div>
187+
</div>
188+
</div>
189+
)
190+
}

frontend/src/types/rust.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ interface PaginationResult<T> {
138138
num_pages: number;
139139
}
140140

141+
interface ChatRequest {
142+
message: string;
143+
}
144+
145+
interface ChatResponse {
146+
response: string;
147+
}
148+
141149
interface FileInfo {
142150
id: number;
143151
key: string;

0 commit comments

Comments
 (0)