Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions app/assets/stylesheets/ai_chat.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
.ai-chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
max-width: 800px;
margin: 20px auto;
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
overflow: hidden;
}

.ai-chat-header {
padding: 1.5rem;
background: rgba(0, 0, 0, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 1rem;
}

.ai-chat-header h1 {
font-size: 1.25rem;
margin: 0;
color: var(--theme-color-text, #333);
display: flex;
align-items: center;
gap: 0.5rem;
}

.beta-badge {
font-size: 0.65rem;
background: var(--theme-color-secondary, #ff4500);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-weight: 800;
letter-spacing: 0.5px;
text-transform: uppercase;
}

.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}

.message {
max-width: 80%;
padding: 1rem;
border-radius: 12px;
line-height: 1.5;
font-size: 0.95rem;
animation: fadeIn 0.3s ease;
}

@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}

to {
opacity: 1;
transform: translateY(0);
}
}

.message.user {
align-self: flex-end;
background: var(--theme-color-primary, #3b49df);
color: white;
border-bottom-right-radius: 2px;
}

.message.ai {
align-self: flex-start;
background: rgba(255, 255, 255, 0.8);
color: #333;
border-bottom-left-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.ai-chat-input-area {
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
gap: 0.75rem;
}

.ai-chat-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
background: white;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}

.ai-chat-input:focus {
border-color: var(--theme-color-primary, #3b49df);
}

.ai-chat-send {
padding: 0.75rem 1.5rem;
background: var(--theme-color-primary, #3b49df);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}

.ai-chat-send:hover {
opacity: 0.9;
}

.ai-chat-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.typing-indicator {
display: flex;
gap: 4px;
padding: 8px;
}

.typing-indicator span {
width: 6px;
height: 6px;
background: #888;
border-radius: 50%;
animation: bounce 1s infinite alternate;
}

.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}

.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}

@keyframes bounce {
from {
transform: translateY(0);
}

to {
transform: translateY(-4px);
}
}
40 changes: 40 additions & 0 deletions app/controllers/ai_chats_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
class AiChatsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_admin!

def index
# Render the initial chat interface
end

def create
user_message = params[:message]
history = params[:history] || []

if user_message.blank?
render json: { error: "Message cannot be blank" }, status: :unprocessable_entity
return
end

chat_service = Ai::ChatService.new(current_user, history: history)
result = chat_service.generate_response(user_message)

render json: {
message: result[:response],
history: result[:history]
}
rescue StandardError => e
Rails.logger.error("AI Chat Error: #{e.message}")
render json: { error: "Something went wrong. Please try again." }, status: :internal_server_error
end

private

def ensure_admin!
return if current_user.any_admin?

respond_to do |format|
format.html { redirect_to root_path, alert: "You are not authorized to access this page." }
format.json { render json: { error: "Unauthorized" }, status: :unauthorized }
end
end
end
54 changes: 54 additions & 0 deletions app/services/ai/chat_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
module Ai
class ChatService
def initialize(user, history: [])
@user = user
@history = history
@ai_client = Ai::Base.new
end

def generate_response(user_message)
@history << { role: "user", text: user_message }

response = @ai_client.call(prompt)

@history << { role: "assistant", text: response }
{ response: response, history: @history }
end

private

def prompt
written_articles = @user.articles.published.order(created_at: :desc).limit(10)
viewed_articles = Article.where(id: @user.page_views.order(created_at: :desc).limit(20).pluck(:article_id))

reading_list_articles = Article.where(id: Reaction.readinglist_for_user(@user).order(created_at: :desc).limit(10).pluck(:reactable_id))

written_context = written_articles.map { |a| "- #{a.title}: #{a.description}" }.join("\n")
viewed_context = viewed_articles.map { |a| "- #{a.title}: #{a.description}" }.join("\n")
reading_list_context = reading_list_articles.map { |a| "- #{a.title}: #{a.description}" }.join("\n")

<<~PROMPT
You are an insightful technical curator and community guide for the DEV community.
Your goal is to provide advice and perspective to the user through the lens of their recent community activity.

User's Recent Writing:
#{written_context.presence || 'No recent articles written.'}

User's Recent Viewing:
#{viewed_context.presence || 'No recent articles viewed.'}

User's Recently Saved to Reading List:
#{reading_list_context.presence || 'No recent articles saved to reading list.'}

Current Conversation History:
#{@history.map { |m| "#{m[:role].capitalize}: #{m[:text]}" }.join("\n")}

Guidelines:
- Be encouraging, helpful, and technically grounded.
- Reference their recent interests (viewed, written, or saved) if it helps provide a more personalized answer.
- Keep responses concise and engaging.
- Use markdown for formatting.
PROMPT
end
end
end
90 changes: 90 additions & 0 deletions app/views/ai_chats/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<%= stylesheet_link_tag 'ai_chat' %>

<div class="ai-chat-container">
<div class="ai-chat-header">
<div class="bot-icon">🤖</div>
<h1>AI Community Buddy <span class="beta-badge">BETA</span></h1>
</div>

<div id="ai-chat-messages" class="ai-chat-messages">
<div class="message ai">
Hello! I'm your community buddy. I see you've been exploring some interesting topics lately. How can I help you today?
</div>
</div>

<form id="ai-chat-form" class="ai-chat-input-area">
<input type="text" id="ai-chat-input" class="ai-chat-input" placeholder="Type your message..." autocomplete="off">
<button type="submit" id="ai-chat-send" class="ai-chat-send">Send</button>
</form>
</div>

<script>
(function() {
const form = document.getElementById('ai-chat-form');
const input = document.getElementById('ai-chat-input');
const messagesContainer = document.getElementById('ai-chat-messages');
const sendButton = document.getElementById('ai-chat-send');

let chatHistory = [];

function appendMessage(role, text) {
const msgDiv = document.createElement('div');
msgDiv.className = `message ${role}`;
msgDiv.innerText = text; // Use innerText for safety, but AI might return markdown
// For real markdown, we'd need a parser, but let's keep it simple or use a safe helper
messagesContainer.appendChild(msgDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return msgDiv;
}

function showTypingIndicator() {
const indicator = document.createElement('div');
indicator.className = 'message ai typing';
indicator.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
messagesContainer.appendChild(indicator);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return indicator;
}

form.addEventListener('submit', async (e) => {
e.preventDefault();
const message = input.value.trim();
if (!message) return;

input.value = '';
input.disabled = true;
sendButton.disabled = true;

appendMessage('user', message);
const indicator = showTypingIndicator();

try {
const response = await fetch('/ai_chats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ message, history: chatHistory })
});

const data = await response.json();
indicator.remove();

if (data.error) {
appendMessage('ai', 'Error: ' + data.error);
} else {
appendMessage('ai', data.message);
chatHistory = data.history;
}
} catch (err) {
indicator.remove();
appendMessage('ai', 'Error: Failed to connect to server.');
} finally {
input.disabled = false;
sendButton.disabled = false;
input.focus();
}
});
})();
</script>
13 changes: 8 additions & 5 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
end
resources :image_uploads, only: [:create]
resources :ai_image_generations, only: [:create]
resources :ai_chats, only: %i[index create]
resources :notifications, only: [:index]
resources :tags, only: [:index] do
collection do
Expand All @@ -176,13 +177,14 @@
post :add_tag
delete :remove_tag
post :create_navigation_link
patch 'update_navigation_link/:navigation_link_id', to: 'subforems#update_navigation_link', as: :update_navigation_link
patch "update_navigation_link/:navigation_link_id", to: "subforems#update_navigation_link",
as: :update_navigation_link
delete :destroy_navigation_link
get :new_page
post :create_page
get 'edit_page/:page_id', to: 'subforems#edit_page', as: :edit_page
patch 'update_page/:page_id', to: 'subforems#update_page', as: :update_page
delete 'destroy_page/:page_id', to: 'subforems#destroy_page', as: :destroy_page
get "edit_page/:page_id", to: "subforems#edit_page", as: :edit_page
patch "update_page/:page_id", to: "subforems#update_page", as: :update_page
delete "destroy_page/:page_id", to: "subforems#destroy_page", as: :destroy_page
end
end
get "/manage", to: "subforems#edit", as: :manage_subforem
Expand Down Expand Up @@ -300,7 +302,8 @@
delete "users/full_delete", to: "users#full_delete", as: :user_full_delete
post "organizations/generate_new_secret", to: "organizations#generate_new_secret"
post "organizations/:id/invite", to: "organizations#invite", as: :organization_invite
get "organizations/confirm_invitation/:token", to: "organizations#confirm_invitation", as: :organization_confirm_invitation
get "organizations/confirm_invitation/:token", to: "organizations#confirm_invitation",
as: :organization_confirm_invitation
post "organizations/confirm_invitation/:token", to: "organizations#confirm_invitation"
post "users/api_secrets", to: "api_secrets#create", as: :users_api_secrets
delete "users/api_secrets/:id", to: "api_secrets#destroy", as: :users_api_secret
Expand Down
Loading
Loading