Skip to content
Open
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
170 changes: 133 additions & 37 deletions app/assets/stylesheets/shared/blocks/_piyo-companion.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,68 +11,164 @@
cursor: pointer;
padding: 0;
position: relative;
transition: transform 0.15s;
}

.piyo-companion__button:hover {
transform: scale(1.1);
}

.piyo-companion__image {
width: 56px;
height: 56px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
width: 64px;
height: 64px;
}

.piyo-companion__bubble {
position: absolute;
bottom: 64px;
bottom: 72px;
right: 0;
background: var(--base, #fff);
border: 1px solid var(--border, #e0e0e0);
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 0.625rem 0.875rem;
max-width: 220px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
animation: piyo-bubble-in 0.2s ease-out;
padding: 0.75rem 1rem;
max-width: 240px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.piyo-companion__bubble::after {
content: '';
position: absolute;
bottom: -7px;
right: 20px;
bottom: -8px;
right: 24px;
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 7px solid var(--base, #fff);
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #fff;
}

.piyo-companion__message {
margin: 0;
font-size: 0.875rem;
line-height: 1.4;
}

.piyo-chat-panel {
position: absolute;
bottom: 72px;
right: 0;
width: 320px;
max-height: 400px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}

.piyo-chat-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e0e0e0;
font-weight: bold;
font-size: 0.875rem;
}

.piyo-chat-panel__close {
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
color: #666;
padding: 0;
line-height: 1;
}

.piyo-chat-panel__close:hover {
color: #333;
}

.piyo-chat-panel__messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 200px;
}

.piyo-chat-panel__msg {
max-width: 85%;
padding: 0.5rem 0.75rem;
border-radius: 12px;
font-size: 0.8125rem;
line-height: 1.5;
word-break: break-word;
}

.piyo-chat-panel__msg--user {
align-self: flex-end;
background: #dbeafe;
color: #1e3a5f;
}

.piyo-chat-panel__msg--assistant {
align-self: flex-start;
background: #f3f4f6;
color: #333;
}

.piyo-chat-panel__input {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid #e0e0e0;
}

.piyo-chat-panel__textarea {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0.5rem;
font-size: 0.8125rem;
resize: none;
min-height: 2.5rem;
max-height: 5rem;
font-family: inherit;
}

.piyo-chat-panel__textarea:focus {
outline: none;
border-color: #60a5fa;
}

.piyo-chat-panel__send {
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
}

.piyo-chat-panel__send:hover {
background: #2563eb;
}

.piyo-chat-panel__send:disabled {
background: #93c5fd;
cursor: not-allowed;
}

.piyo-companion__badge {
position: absolute;
top: -3px;
right: -3px;
width: 10px;
height: 10px;
background: var(--danger, #ef4444);
top: -4px;
right: -4px;
width: 12px;
height: 12px;
background: #ef4444;
border-radius: 50%;
border: 2px solid var(--base, #fff);
}

@keyframes piyo-bubble-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
border: 2px solid #fff;
}
52 changes: 52 additions & 0 deletions app/controllers/api/textbooks/piyo_chat_messages_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

class API::Textbooks::PiyoChatMessagesController < API::BaseController
include TextbookFeatureGuard
before_action :require_textbook_enabled

def index
messages = current_user.piyo_chat_messages
.where(textbook_section_id: params[:section_id])
.order(created_at: :asc)

render json: messages.map { |m| message_json(m) }
end

def create
section = Textbook::Section.find(params[:section_id])

user_message = current_user.piyo_chat_messages.create!(
textbook_section_id: section.id,
role: 'user',
content: params[:content]
)

response_text = PiyoChatService.respond(
user: current_user,
section: section,
message: params[:content]
)

assistant_message = current_user.piyo_chat_messages.create!(
textbook_section_id: section.id,
role: 'assistant',
content: response_text
)

render json: {
user_message: message_json(user_message),
assistant_message: message_json(assistant_message)
}, status: :created
end

private

def message_json(message)
{
id: message.id,
role: message.role,
content: message.content,
created_at: message.created_at
}
end
end
129 changes: 129 additions & 0 deletions app/javascript/controllers/piyo_chat_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Controller } from 'stimulus'

export default class extends Controller {
static targets = ['panel', 'messages', 'input', 'sendButton', 'badge']
static values = { sectionId: Number, open: { type: Boolean, default: false } }

connect() {
this.loadHistory()
}

toggle() {
this.openValue = !this.openValue
this.panelTarget.classList.toggle('is-hidden', !this.openValue)

if (this.openValue) {
this.hideBadge()
this.scrollToBottom()
this.inputTarget.focus()
}
}

close() {
this.openValue = false
this.panelTarget.classList.add('is-hidden')
}

async send() {
const content = this.inputTarget.value.trim()
if (!content) return

this.inputTarget.value = ''
this.appendMessage('user', content)
this.setLoading(true)

try {
const response = await fetch('/api/textbooks/piyo_chat_messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken
},
body: JSON.stringify({
section_id: this.sectionIdValue,
content: content
})
})

if (!response.ok) throw new Error('送信に失敗しました')

const data = await response.json()
this.appendMessage('assistant', data.assistant_message.content)
} catch (error) {
this.appendMessage('assistant', 'ごめんなさい、エラーが発生しました。もう一度試してみてください。')
} finally {
this.setLoading(false)
}
}

onKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
this.send()
}
}

async loadHistory() {
try {
const response = await fetch(
`/api/textbooks/piyo_chat_messages?section_id=${this.sectionIdValue}`
)
if (!response.ok) return

const messages = await response.json()
messages.forEach((msg) => this.appendMessage(msg.role, msg.content))
} catch {
// 履歴読み込み失敗は無視
}
}

appendMessage(role, content) {
const el = document.createElement('div')
el.className = `piyo-chat-message piyo-chat-message--${role}`
el.textContent = content
this.messagesTarget.appendChild(el)
this.scrollToBottom()

if (!this.openValue && role === 'assistant') {
this.showBadge()
}
}

setLoading(loading) {
this.sendButtonTarget.disabled = loading

if (loading) {
this.loadingEl = document.createElement('div')
this.loadingEl.className = 'piyo-chat-message piyo-chat-message--assistant piyo-chat-message--loading'
this.loadingEl.textContent = '...'
this.messagesTarget.appendChild(this.loadingEl)
this.scrollToBottom()
} else if (this.loadingEl) {
this.loadingEl.remove()
this.loadingEl = null
}
}

scrollToBottom() {
requestAnimationFrame(() => {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
})
}

showBadge() {
if (this.hasBadgeTarget) {
this.badgeTarget.classList.remove('is-hidden')
}
}

hideBadge() {
if (this.hasBadgeTarget) {
this.badgeTarget.classList.add('is-hidden')
}
}

get csrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]')
return meta ? meta.content : ''
}
}
11 changes: 11 additions & 0 deletions app/models/piyo_chat_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class PiyoChatMessage < ApplicationRecord
belongs_to :user
belongs_to :section, class_name: 'Textbook::Section', foreign_key: 'textbook_section_id', inverse_of: :piyo_chat_messages

validates :role, presence: true, inclusion: { in: %w[user assistant] }
validates :content, presence: true

scope :recent, -> { order(created_at: :asc) }
end
Loading