CCoE AIチャット構築
概要
CCoEへの問い合わせや申請に対応してくれるAIチャットを作成してみます。
主な機能は以下の通りです。
- AWSに関連する質問に回答
- AWSアカウントの発行
3回に分けて構築していきます。
1.基本的なAIチャットの構築
2.MCPを使用して、AWSドキュメントから情報を回答可能に
3.AWSアカウントの発行機能を追加
今回は基本的なAIチャットの構築を行っていきます!
※セキュリティに関する注意事項
本記事はPoC(概念実証)を目的としているため、本番環境では、セキュリティを考慮した構成を取るように検討をしてください。
構成
今回はStrands Agentsを使ってみます。
選定理由は使ってみたいからです(笑)。
また、こういったPoC的にチャットアプリを作る場合、Streamlitが多いかと思いますが、今回はFlask + Ajaxを使用します。
Streamlitを利用しない理由は後述します。
準備
周辺リソースを作成します。
- Cognito User Pool(認証用)
- DynamoDBテーブル
- メッセージ保存用
- セッション保存用
Cognito User Pool
まずは認証用に必要なCognito User Poolを作成します。
1.AWS Management Consoleにログインし、Cognitoサービスを開きます。
久しぶりにマネジメントコンソールでCognitoの画面を開きましたが、UIちょっとかっこよくなってますかね
2.ユーザープール
からユーザープールを作成
をクリックします。
3.各設定値を埋めてユーザープールを作成します。
設定項目にないものはデフォルト
項目 | 設定値 |
---|---|
アプリケーションに名前を付ける | CCoE AI Chat |
サインイン識別子のオプション | ■メールアドレス |
4.概要に移動します。
5.アプリケーションクライアント
から作成されているアプリケーションクライアントの詳細を開き、ログインページ
タブからマネージドログインページの設定
の編集をクリックします。
6.以下の項目を編集します。
項目 | 設定値 |
---|---|
許可されているコールバックURL | http://localhost:8080/authorize |
許可されているログアウトURL | http://localhost:8080/login |
OAuth 2.0 許可タイプ |
認証コード付与 / 暗黙的な付与
|
OpenID Connect のスコープ |
aws.cognito.signin.user.admin / E メール / OpenID / プロファイル
|
7.アプリケーションクライアントに関する情報
も編集します。
8.以下の項目を編集します。
項目 | 設定値 |
---|---|
認証フロー |
■ユーザー名とパスワード (ALLOW_USER_PASSWORD_AUTH) を使用してサインインします ■既存の認証済みセッション (ALLOW_REFRESH_TOKEN_AUTH) から新しいユーザートークンを取得します
|
高度なセキュリティ設定 |
■トークンの取り消しを有効化 ■ユーザー存在エラーの防止
|
DynamoDBテーブルの作成
DynamoDBテーブルを2つ作成します。
一つはメッセージを保存するテーブル、もう一つはセッション(新しい会話を始めると一つのセッションが作成される)を管理するテーブルです。
メッセージ保存用のテーブル作成
1.AWS Management Consoleにログインし、DynamoDBサービスを開き、テーブルからテーブルの作成をクリックします。
このロボットの存在を知っている人が世界に何人いるか考えると少し悲しくなりました。。
2.以下の項目を入力します。
項目 | 設定値 |
---|---|
テーブル名 | ccoe_ai_chat_messages_table |
パーティションキー |
id / 文字列
|
3.グローバルセカンダリインデックスを作成します。
4.以下の項目を入力します。
項目 | 設定値 |
---|---|
パーティションキー |
session_id / 文字列
|
ソートキー |
created_at / 文字列
|
インデックス名 | session_id_index |
セッション管理用のテーブル作成
1.同様にDynamoDBサービスからテーブルの作成をクリックします。
2.以下の項目を入力します。
項目 | 設定値 |
---|---|
テーブル名 | ccoe_ai_chat_sessions_table |
パーティションキー |
id / 文字列
|
3.同様にグローバルセカンダリインデックスを作成します。
4.以下の項目を入力します。
項目 | 設定値 |
---|---|
パーティションキー |
user_id / 文字列
|
ソートキー |
updated_at / 文字列
|
インデックス名 | user_id_index |
Bedrockモデルアクセス
Bedrockを使用するため、モデルアクセスを有効化しておきましょう。
1.AWS Management Consoleにログインし、Bedrockサービスを開き、モデルアクセスからモデルアクセスを変更をクリックします。
2.Claude Sonnet 4
を選択し、次に進みます。
3.確認して送信
をクリックします。
Flaskアプリの作成
ディレクトリ構成
以下のようなディレクトリ構成でファイルを作成します。
数が多いので、細かい説明は基本的に省きます。
.
├── app/
│ ├── __init__.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── main.py
│ ├──auth
│ │ ├── app.py
│ ├──utils
│ │ ├── flask_utils.py
├── frontend/
│ ├── static/
│ │ ├── img/(ファイルは省略)
│ │ ├── js/
│ │ | ├── components/
│ │ | | └── sidebar.js
│ │ | └── index.js
│ │ ├── style/
│ │ | ├── base.css
│ │ | └── index.css
│ ├── templates/
│ │ ├── base.html
│ │ └── index.html
├── .env
├── apprunner.yaml
├── requirements.txt
├── wsgi.py
ファイルの作成
1.wsgi.py
# wsgi.py
from app import create_app
import os
app = create_app()
if __name__ == '__main__':
port = int(os.environ.get('PORT', 8080))
app.run(debug=True, host='0.0.0.0', port=port)
2.app/__init__.py
# app/__init__.py
import os
from flask import Flask
from flask_cors import CORS
from authlib.integrations.flask_client import OAuth
from datetime import timedelta
def create_app(testing=False):
app = Flask(__name__, static_folder='../frontend/static', template_folder='../frontend/templates')
# 基本設定
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('ENV') == 'prd'
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
if testing:
app.config['TESTING'] = True
# CORS設定
CORS(app)
# Cognito Setup
oauth = OAuth(app)
# 環境変数
cognito_region = os.environ.get('AWS_REGION', 'ap-northeast-1')
cognito_domain = os.environ.get('COGNITO_DOMAIN')
user_pool_id = os.environ.get('COGNITO_USER_POOL_ID')
client_id = os.environ.get('COGNITO_CLIENT_ID')
client_secret = os.environ.get('COGNITO_CLIENT_SECRET')
oauth.register(
name='oidc',
client_id=client_id,
client_secret=client_secret,
client_kwargs={'scope': 'openid email profile'},
server_metadata_url=f"https://cognito-idp.{cognito_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration",
authorize_url=f"https://{cognito_domain}.auth.{cognito_region}.amazoncognito.com/oauth2/authorize",
access_token_url=f"https://{cognito_domain}.auth.{cognito_region}.amazoncognito.com/oauth2/token",
)
# ルートの登録
from app.routes import register_routes
register_routes(app, oauth)
return app
3.app/routes/__init__.py
# app/routes/__init__.py
from flask import render_template, session, redirect, url_for, request
from app.auth.app import login_required
import urllib
import os
from ..utils.flask_utils import get_base_url
def register_routes(app, oauth):
from app.routes.main import main_bp
app.register_blueprint(main_bp)
@app.route('/login')
def login():
# 環境変数からベースURLを取得するか、リクエストから動的に構築
base_url = os.environ.get('BASE_URL')
if not base_url:
# リクエストからベースURLを構築
if request.headers.get('X-Forwarded-Proto') and request.headers.get('X-Forwarded-Host'):
base_url = f"{request.headers.get('X-Forwarded-Proto')}://{request.headers.get('X-Forwarded-Host')}"
else:
base_url = request.base_url.rsplit('/', 1)[0]
redirect_uri = f"{base_url}/authorize"
print(f"Redirect URI: {redirect_uri}") # デバッグ用
return oauth.oidc.authorize_redirect(redirect_uri)
@app.route('/authorize')
def authorize():
try:
token = oauth.oidc.authorize_access_token()
user = token.get('userinfo')
session['user'] = user
return redirect(url_for('main.home'))
except Exception as e:
print(f"認証エラー: {str(e)}")
return f"認証エラー: {str(e)}", 400
@app.route('/logout')
def logout():
# Flaskセッションをクリア
session.clear()
# 環境変数
cognito_domain = os.environ.get('COGNITO_DOMAIN')
region = os.environ.get('AWS_REGION', 'ap-northeast-1')
client_id = os.environ.get('COGNITO_CLIENT_ID')
base_url = get_base_url()
# ログアウト後のリダイレクト先
logout_uri = f"{base_url}/login"
# URLエンコード
import urllib.parse
encoded_logout_uri = urllib.parse.quote(logout_uri)
# Cognito ログアウトURLの構築
cognito_logout_url = (
f"https://{cognito_domain}.auth.{region}.amazoncognito.com/logout"
f"?client_id={client_id}"
f"&logout_uri={encoded_logout_uri}"
f"&response_type=code"
)
return redirect(cognito_logout_url)
4.app/routes/main.py
# app/routes/main.py
from flask import Blueprint, render_template, session
from app.auth.app import login_required
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def home():
user = session.get('user')
return render_template('index.html', user=user)
@main_bp.route('/health')
def health_check():
return {"status": "healthy"}, 200
5.app/auth/app.py
# app/auth.py
from functools import wraps
from flask import session, redirect, url_for, request
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# セッションにユーザー情報があるか確認
if 'user' not in session:
# ログインページにリダイレクト(現在のURLをnext_urlとして保存)
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
6.app/utils/flask_utils.py
from flask import request
import os
def get_base_url():
"""環境に応じたベースURLを取得する"""
# 環境変数で明示的に設定されている場合はそれを使用
base_url = os.environ.get('BASE_URL')
if base_url:
return base_url
# リクエストコンテキストからの取得を試みる
from flask import request
if request:
if request.headers.get('X-Forwarded-Proto') and request.headers.get('X-Forwarded-Host'):
# プロキシ背後の場合(AppRunnerなど)
return f"{request.headers.get('X-Forwarded-Proto')}://{request.headers.get('X-Forwarded-Host')}"
return request.url_root.rstrip('/')
# フォールバック(開発環境用)
return "http://localhost:8080"
7.frontend/templates/base.html
<!-- frontend/templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CCoE AIチャット</title>
<link rel="icon" href="{{ url_for('static', filename='img/ccoe_ai.png') }}" type="image/x-icon">
<link rel="stylesheet" href="../static/style/base.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
{% block head_content %}
{% endblock %}
</head>
<body>
<!-- ヘッダー -->
<header>
<div class="logo">CCoE<span>AIチャット</span></div>
</header>
<!-- サイドバートグルボタン -->
<button class="sidebar-toggle" id="sidebarToggle">
<i class="fas fa-bars"></i>
</button>
<!-- サイドバー -->
<aside class="sidebar" id="sidebar">
<nav class="sidebar-menu">
<ul>
<li><a href="{{ url_for('main.home') }}"><i class="fas fa-home"></i> CCoE AI</a></li>
</ul>
</nav>
<!-- ユーザー情報セクション -->
<div class="user-info">
<div class="user-avatar">
<i class="fas fa-user-circle"></i>
</div>
<div class="user-details">
<div class="user-name">{{ session['user'].name }}</div>
<div class="user-email">{{ session['user'].email }}</div>
</div>
<div class="user-actions">
<a href="{{ url_for('logout') }}" title="ログアウト"><i class="fas fa-sign-out-alt"></i></a>
</div>
</div>
</aside>
<!-- メインコンテンツ -->
<main class="main-content" id="mainContent">
{% block main_content %}
{% endblock %}
</main>
<!-- フッター -->
<footer>
<p>2025 CCoE AI</p>
</footer>
<script src="../static/js/components/sidebar.js"></script>
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.1001.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/amazon-cognito-identity-js@5.2.10/dist/amazon-cognito-identity.min.js"></script>
</html>
8.frontend/templates/index.html
<!-- frontend/templates/index.html -->
{% extends "base.html" %}
{% block head_content %}
<link rel="stylesheet" href="../static/style/index.css">
<script>
window.USER_EMAIL = "{{ session['user'].email }}";
</script>
{% endblock %}
{% block main_content %}
<div class="page-header">
<h1>CCoE AIチャット</h1>
<div class="session-controls">
<div class="session-dropdown">
<button class="session-dropdown-btn" id="currentSessionBtn">
<span id="currentSessionName">新しいチャット</span>
<i class="fas fa-chevron-down"></i>
</button>
<div class="session-dropdown-content" id="sessionDropdown">
<div class="session-search">
<input type="text" id="sessionSearch" placeholder="チャットを検索...">
</div>
<div class="session-list" id="sessionList">
<!-- セッション一覧がここに表示されます -->
</div>
<div class="session-actions">
<button id="newSessionBtn"><i class="fas fa-plus"></i> 新しいチャット</button>
</div>
</div>
</div>
<button id="renameSessionBtn" title="チャット名を変更"><i class="fas fa-edit"></i></button>
</div>
</div>
<div class="chat-container">
<div class="ccoe-info">
<div class="ccoe-profile">
<div class="ccoe-avatar">
<img src="../static/img/ccoe_ai.png" alt="CCoE AI" id="ccoeAvatar">
</div>
<div class="ccoe-details">
<h2>CCoE</h2>
<p class="ccoe-title"></p>
<p class="ccoe-description">
AWSなどのクラウドサービスを活用し、AIを駆使して企業の成長を支援するCCoE(Cloud Center of Excellence)です。クラウド利活用やアカウント発行の支援、AIを活用した業務効率化の提案などを行っています。
</p>
</div>
</div>
<div class="ccoe-topics">
<h3>話題のサジェスト</h3>
<div class="topic-buttons">
<button class="topic-btn" data-topic="AWSとはなんですか?">AWSとはなんですか?</button>
<button class="topic-btn" data-topic="AIを活用した業務効率化の方法">AIを活用した業務効率化の方法</button>
<button class="topic-btn" data-topic="クラウドサービスの利活用事例">クラウドサービスの利活用事例</button>
<button class="topic-btn" data-topic="AWSアカウント発行の依頼">AWSアカウント発行の依頼</button>
</div>
</div>
</div>
<div class="chat-main">
<div class="chat-messages" id="chatMessages">
<div class="message system">
<div class="message-avatar">
<img src="../static/img/ccoe_ai.png" alt="CCoE AI" class="agent-icon">
</div>
<div class="message-content">
<p>AWSに関するご相談やご質問をお気軽にどうぞ</p>
</div>
</div>
</div>
<div class="chat-input-container">
<div class="chat-input-wrapper">
<textarea id="chatInput" placeholder="CCoE AIに質問してみましょう..."></textarea>
<div class="chat-input-actions">
<button id="sendBtn"><i class="fas fa-paper-plane"></i></button>
</div>
</div>
</div>
</div>
</div>
<!-- セッション名変更モーダル -->
<div class="modal" id="renameSessionModal">
<div class="modal-content">
<span class="close-modal">×</span>
<h2>チャット名を変更</h2>
<input type="text" id="newSessionNameInput" placeholder="新しいチャット名を入力">
<div class="modal-actions">
<button id="saveSessionNameBtn">保存</button>
<button id="cancelRenameBtn">キャンセル</button>
</div>
</div>
</div>
<!-- 削除確認モーダル -->
<div class="modal" id="deleteConfirmModal">
<div class="modal-content">
<span class="close-modal">×</span>
<h2>チャットの削除</h2>
<p>チャット「<span id="deleteSessionName"></span>」を削除してもよろしいですか?</p>
<p class="warning-text">この操作は取り消せません。</p>
<div class="modal-actions">
<button id="confirmDeleteBtn" class="danger-btn">削除する</button>
<button id="cancelDeleteBtn">キャンセル</button>
</div>
</div>
</div>
<!-- JavaScriptファイルを読み込み -->
<script src="../static/js/index.js"></script>
{% endblock %}
9.frontend/static/js/components/sidebar.js
// frontend/static/js/components/sidebar.js
document.addEventListener('DOMContentLoaded', function() {
// 要素の取得
const sidebarToggle = document.getElementById('sidebarToggle');
const sidebar = document.getElementById('sidebar');
const mainContent = document.getElementById('mainContent');
// デバッグ情報
console.log('サイドバートグル要素:', sidebarToggle);
console.log('サイドバー要素:', sidebar);
console.log('メインコンテンツ要素:', mainContent);
// 初期状態を設定
let sidebarOpen = false;
// トグルボタンにクリックイベントを設定
if (sidebarToggle) {
sidebarToggle.onclick = function() {
// 状態を反転
sidebarOpen = !sidebarOpen;
console.log('サイドバーの状態を変更:', sidebarOpen ? '開' : '閉');
// サイドバーの表示/非表示を設定
if (sidebar) {
if (sidebarOpen) {
sidebar.classList.add('open');
} else {
sidebar.classList.remove('open');
}
}
// メインコンテンツのマージンを調整
if (mainContent) {
if (sidebarOpen) {
mainContent.classList.add('sidebar-open');
} else {
mainContent.classList.remove('sidebar-open');
}
}
// アイコンを設定
const icon = this.querySelector('i');
if (icon) {
if (sidebarOpen) {
icon.className = 'fas fa-times'; // ×アイコン
} else {
icon.className = 'fas fa-bars'; // ≡アイコン
}
}
};
}
});
document.addEventListener('DOMContentLoaded', function() {
const aiServicesToggle = document.getElementById('aiServicesToggle');
const aiServicesSubMenu = document.getElementById('aiServicesSubMenu');
aiServicesToggle.onclick = function() {
// サブメニューの表示/非表示をトグル
if (aiServicesSubMenu.style.display === 'none' || aiServicesSubMenu.style.display === '') {
aiServicesSubMenu.style.display = 'block';
} else {
aiServicesSubMenu.style.display = 'none';
}
};
});
10.frontend/static/js/index.js
// frontend/static/js/index.js
document.addEventListener('DOMContentLoaded', function () {
// アプリケーション全体の状態管理
const App = {
// ユーザー情報
user: {
id: window.USER_EMAIL || 'anonymous'
},
// セッション情報
session: {
id: localStorage.getItem('CcoeAiChatSessionId') || null,
name: localStorage.getItem('CcoeAiChatSessionName') || '新しいチャット',
// セッション情報を保存
save: function (id, name) {
this.id = id;
this.name = name;
localStorage.setItem('CcoeAiChatSessionId', id);
localStorage.setItem('CcoeAiChatSessionName', name);
DOM.currentSessionNameSpan.textContent = name;
}
},
// CCoEの処理状態
ccoe: {
isProcessing: false
}
};
// DOM要素の参照
const DOM = {
chatMessages: document.getElementById('chatMessages'),
chatInput: document.getElementById('chatInput'),
sendBtn: document.getElementById('sendBtn'),
currentSessionBtn: document.getElementById('currentSessionBtn'),
currentSessionNameSpan: document.getElementById('currentSessionName'),
sessionDropdown: document.getElementById('sessionDropdown'),
sessionList: document.getElementById('sessionList'),
newSessionBtn: document.getElementById('newSessionBtn'),
renameSessionBtn: document.getElementById('renameSessionBtn'),
renameSessionModal: document.getElementById('renameSessionModal'),
newSessionNameInput: document.getElementById('newSessionNameInput'),
saveSessionNameBtn: document.getElementById('saveSessionNameBtn'),
cancelRenameBtn: document.getElementById('cancelRenameBtn'),
closeModalBtns: document.querySelectorAll('.close-modal'),
sessionSearch: document.getElementById('sessionSearch'),
deleteConfirmModal: document.getElementById('deleteConfirmModal'),
deleteSessionName: document.getElementById('deleteSessionName'),
confirmDeleteBtn: document.getElementById('confirmDeleteBtn'),
cancelDeleteBtn: document.getElementById('cancelDeleteBtn'),
topicButtons: document.querySelectorAll('.topic-btn')
};
// ユーティリティ関数
const Utils = {
// モーダルを閉じる
closeModal: function (modalElement) {
modalElement.style.display = 'none';
},
// AJAXリクエスト送信
sendRequest: function (url, method, data, callbacks) {
$.ajax({
url: url,
type: method,
contentType: data ? 'application/json' : undefined,
data: data ? (method === 'GET' ? data : JSON.stringify(data)) : undefined,
beforeSend: callbacks.beforeSend,
success: callbacks.success,
error: function (xhr, status, error) {
console.error(`Error (${url}):`, error);
if (callbacks.error) callbacks.error(xhr, status, error);
},
complete: callbacks.complete
});
},
// テキストを省略
truncateText: function (text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
},
// 通知表示
showNotification: function (message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.innerHTML = `
<div class="notification-content">
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'}"></i>
<span>${message}</span>
</div>
<button class="notification-close"><i class="fas fa-times"></i></button>
`;
// 閉じるボタンのイベント
notification.querySelector('.notification-close').addEventListener('click', function () {
notification.classList.add('notification-hide');
setTimeout(() => {
notification.remove();
}, 300);
});
// 通知を表示
document.body.appendChild(notification);
// 3秒後に自動的に閉じる
setTimeout(() => {
notification.classList.add('notification-hide');
setTimeout(() => {
notification.remove();
}, 300);
}, 3000);
}
};
// チャット機能
const Chat = {
// メッセージ追加
addMessage: function (type, content, save = true) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}`;
const avatarDiv = document.createElement('div');
avatarDiv.className = 'message-avatar';
if (type === 'user') {
avatarDiv.innerHTML = '<i class="fas fa-user"></i>';
} else {
avatarDiv.innerHTML = '<img src="../static/img/ccoe_ai.png" alt="CCoE AI" class="agent-icon">';
}
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.innerHTML = `<p>${this.formatMessage(content)}</p>`;
if (type === 'user') {
messageDiv.appendChild(contentDiv);
messageDiv.appendChild(avatarDiv);
} else {
messageDiv.appendChild(avatarDiv);
messageDiv.appendChild(contentDiv);
}
DOM.chatMessages.appendChild(messageDiv);
this.scrollToBottom();
return messageDiv;
},
// 「少々お待ちください」メッセージ追加
addThinkingMessage: function () {
const messageId = 'thinking-' + Date.now();
const messageDiv = document.createElement('div');
messageDiv.className = 'message system thinking';
messageDiv.id = messageId;
messageDiv.innerHTML = `
<div class="message-avatar">
<img src="../static/img/ccoe_ai.png" alt="CCoE AI" class="agent-icon">
</div>
<div class="message-content">
<p>少々お待ちください<span class="dot-animation"><span>.</span><span>.</span><span>.</span></span></p>
</div>
`;
DOM.chatMessages.appendChild(messageDiv);
this.scrollToBottom();
return messageId;
},
// 「少々お待ちください」メッセージ削除
removeThinkingMessage: function (messageId) {
document.getElementById(messageId)?.remove();
},
// メッセージのフォーマット
formatMessage: function (text) {
if (!text) return '';
// 改行をHTMLの改行に変換
let formattedText = text.replace(/\n/g, '<br>');
// 強調(*text*)をHTMLの強調に変換
formattedText = formattedText.replace(/\*([^*]+)\*/g, '<strong>$1</strong>');
return formattedText;
},
// チャットを最下部にスクロール
scrollToBottom: function () {
DOM.chatMessages.scrollTop = DOM.chatMessages.scrollHeight;
},
// メッセージ送信
sendMessage: function () {
const message = DOM.chatInput.value.trim();
if (message === '' || App.chairman.isProcessing) return;
try {
App.chairman.isProcessing = true;
DOM.sendBtn.disabled = true;
DOM.sendBtn.classList.add('disabled');
// ユーザーメッセージをUIに追加
this.addMessage('user', message);
DOM.chatInput.value = '';
// 「少々お待ちください」メッセージを表示
const thinkingId = this.addThinkingMessage();
// APIリクエスト
Utils.sendRequest('/chat', 'POST', {
session_id: App.session.id,
message: message
}, {
success: function (response) {
// 「少々お待ちください」メッセージを削除
Chat.removeThinkingMessage(thinkingId);
// CCoEの返答をUIに追加
Chat.addMessage('system', response.response);
// セッション名が未設定の場合は自動的に設定
if (App.session.name === "新しいチャット" && response.session_name) {
App.session.save(App.session.id, response.session_name);
SessionManager.updateSessionList();
}
},
error: function (xhr, status, error) {
// 「考え中」メッセージを削除
Chat.removeThinkingMessage(thinkingId);
// エラーメッセージを表示
Chat.addMessage('system', `メッセージの送信に失敗しました。もう一度お試しください。\nエラー: ${error}`);
Utils.showNotification('メッセージの送信に失敗しました', 'error');
},
complete: function () {
App.ccoe.isProcessing = false;
DOM.sendBtn.disabled = false;
DOM.sendBtn.classList.remove('disabled');
}
});
} catch (error) {
console.error('メッセージの送信に失敗しました:', error);
this.addMessage('system', 'メッセージの送信に失敗しました。もう一度お試しください。');
App.ccoe.isProcessing = false;
DOM.sendBtn.disabled = false;
DOM.sendBtn.classList.remove('disabled');
}
}
};
// セッション管理
const SessionManager = {
// セッションを削除するためのデータを保持
sessionToDelete: null,
// セッション一覧を読み込む
loadList: function () {
Utils.sendRequest('/list_sessions', 'POST', {
user_id: App.user.id,
type: 'ccoe'
}, {
success: function (response) {
DOM.sessionList.innerHTML = '';
if (response.sessions && response.sessions.length > 0) {
response.sessions.forEach(session => {
const sessionItem = SessionManager.createSessionItem(session, session.id === App.session.id);
DOM.sessionList.appendChild(sessionItem);
});
} else {
DOM.sessionList.innerHTML = '<div class="no-sessions-message">チャット履歴がありません</div>';
}
},
error: function () {
DOM.sessionList.innerHTML = '<div class="no-sessions-message">チャット履歴の読み込みに失敗しました</div>';
}
});
},
// セッションアイテムのHTMLを生成
createSessionItem: function (session, isActive) {
const sessionItem = document.createElement('div');
sessionItem.className = 'session-item';
if (isActive) {
sessionItem.classList.add('active');
}
const date = new Date(session.updated_at);
const formattedDate = this.formatDate(date);
sessionItem.innerHTML = `
<div class="session-item-content" data-id="${session.id}">
<div class="session-item-name">${this.escapeHtml(session.name)}</div>
<div class="session-item-date">${formattedDate}</div>
</div>
<div class="session-item-actions">
<button class="session-delete-btn" data-id="${session.id}" data-name="${this.escapeHtml(session.name)}">
<i class="fas fa-trash"></i>
</button>
</div>
`;
// セッション選択イベント
sessionItem.querySelector('.session-item-content').addEventListener('click', function () {
const sessionId = this.getAttribute('data-id');
SessionManager.loadSession(sessionId);
});
// 削除ボタンのイベント
sessionItem.querySelector('.session-delete-btn').addEventListener('click', function (e) {
e.stopPropagation();
const sessionId = this.getAttribute('data-id');
const sessionName = this.getAttribute('data-name');
SessionManager.openDeleteConfirmModal(sessionId, sessionName);
});
return sessionItem;
},
// HTMLエスケープ
escapeHtml: function (text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// 日付のフォーマット
formatDate: function (date) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const isToday = date >= today;
const isYesterday = date >= yesterday && date < today;
if (isToday) {
return `今日 ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
} else if (isYesterday) {
return `昨日 ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
} else {
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
}
},
// 新しいセッションを作成
createNew: function () {
Utils.sendRequest('/create_session', 'POST', {
user_id: App.user.id,
name: "新しいチャット",
type: 'ccoe'
}, {
success: function (response) {
// セッション情報を保存
App.session.save(response.session_id, response.name);
// チャットをクリア
DOM.chatMessages.innerHTML = '';
// 初期メッセージを表示
Chat.addMessage('system', 'こんにちは、CCoE AIです。どのようなことでもお気軽にお尋ねください。私の経験や知見をもとにお答えします。');
// セッションドロップダウンを閉じる
DOM.sessionDropdown.classList.remove('show');
// セッション一覧を更新
SessionManager.loadList();
},
error: function () {
Utils.showNotification('新しいチャットの作成に失敗しました', 'error');
}
});
},
// セッションを読み込む
loadSession: function (sessionId) {
Utils.sendRequest('/get_messages', 'POST', {
session_id: sessionId
}, {
beforeSend: function () {
// 読み込み中メッセージを表示
DOM.chatMessages.innerHTML = '';
Chat.addMessage('system', 'チャット履歴を読み込んでいます...');
},
success: function (response) {
// セッション情報を更新
App.session.save(sessionId, response.session_name);
// チャットをクリア
DOM.chatMessages.innerHTML = '';
// メッセージ履歴を表示
if (response.messages && response.messages.length > 0) {
response.messages.forEach(msg => {
Chat.addMessage(msg.role, msg.content, false);
});
} else {
// デフォルトの挨拶メッセージ
Chat.addMessage('system', 'こんにちは、CCoE AIです。どのようなことでもお気軽にお尋ねください。私の経験や知見をもとにお答えします。');
}
// セッションドロップダウンを閉じる
DOM.sessionDropdown.classList.remove('show');
// セッション一覧を更新
SessionManager.updateSessionList();
},
error: function () {
DOM.chatMessages.innerHTML = '';
Chat.addMessage('system', 'チャット履歴の読み込みに失敗しました。もう一度お試しください。');
Utils.showNotification('チャット履歴の読み込みに失敗しました', 'error');
}
});
},
// セッション一覧を更新
updateSessionList: function () {
const sessionItems = DOM.sessionList.querySelectorAll('.session-item');
sessionItems.forEach(item => {
const itemId = item.querySelector('.session-item-content').getAttribute('data-id');
if (itemId === App.session.id) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
},
// セッション名を更新
updateName: function (newName) {
if (!newName || !App.session.id) return;
Utils.sendRequest('/update_session', 'POST', {
session_id: App.session.id,
name: newName
}, {
success: function () {
// セッション名を更新
App.session.save(App.session.id, newName);
// モーダルを閉じる
Utils.closeModal(DOM.renameSessionModal);
// セッション一覧を更新
SessionManager.loadList();
Utils.showNotification('チャット名を更新しました', 'success');
},
error: function () {
Utils.showNotification('チャット名の更新に失敗しました', 'error');
}
});
},
// 削除確認モーダルを開く
openDeleteConfirmModal: function (sessionId, sessionName) {
// 削除対象のセッション情報を保存
this.sessionToDelete = {
id: sessionId,
name: sessionName
};
// モーダルにセッション名を表示
DOM.deleteSessionName.textContent = sessionName;
// モーダルを表示
DOM.deleteConfirmModal.style.display = 'block';
},
// セッション削除
deleteSession: function () {
if (!this.sessionToDelete) return;
const sessionId = this.sessionToDelete.id;
const isDeletingCurrentSession = sessionId === App.session.id;
Utils.sendRequest('/delete_session', 'POST', {
session_id: sessionId
}, {
success: function () {
// セッションリストを更新
SessionManager.loadList();
// 現在のセッションを削除した場合は新しいセッションを作成
if (isDeletingCurrentSession) {
// チャットメッセージをクリア
DOM.chatMessages.innerHTML = '';
// 新しいセッションを作成
SessionManager.createNew();
}
// 成功メッセージを表示
Utils.showNotification('チャットを削除しました', 'success');
},
error: function () {
Utils.showNotification('チャットの削除に失敗しました', 'error');
}
});
// モーダルを閉じる
DOM.deleteConfirmModal.style.display = 'none';
this.sessionToDelete = null;
},
// セッションの検索
filterSessions: function (searchTerm) {
const sessionItems = DOM.sessionList.querySelectorAll('.session-item');
sessionItems.forEach(item => {
const sessionName = item.querySelector('.session-item-name').textContent.toLowerCase();
if (sessionName.includes(searchTerm.toLowerCase())) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
}
};
// イベントハンドラーの設定
function setupEventListeners() {
// 現在のセッション名を表示
DOM.currentSessionNameSpan.textContent = App.session.name;
// モーダル関連のイベントリスナー設定
DOM.closeModalBtns.forEach(btn => {
btn.addEventListener('click', function () {
let modal = this.closest('.modal');
if (modal) {
modal.style.display = 'none';
}
});
});
DOM.cancelRenameBtn.addEventListener('click', () => Utils.closeModal(DOM.renameSessionModal));
// モーダル外クリックで閉じる
window.addEventListener('click', function (event) {
if (event.target === DOM.renameSessionModal) Utils.closeModal(DOM.renameSessionModal);
if (event.target === DOM.deleteConfirmModal) DOM.deleteConfirmModal.style.display = 'none';
// セッションドロップダウン
if (!event.target.matches('.session-dropdown-btn') &&
!event.target.matches('.session-dropdown-btn *') &&
!event.target.matches('.session-dropdown-content') &&
!event.target.matches('.session-dropdown-content *')) {
DOM.sessionDropdown.classList.remove('show');
}
});
// セッションドロップダウンの表示/非表示を切り替え
DOM.currentSessionBtn.addEventListener('click', function () {
DOM.sessionDropdown.classList.toggle('show');
if (DOM.sessionDropdown.classList.contains('show')) {
SessionManager.loadList();
}
});
// セッション検索機能
DOM.sessionSearch.addEventListener('input', function () {
SessionManager.filterSessions(this.value);
});
// 新しいセッションを作成
DOM.newSessionBtn.addEventListener('click', function () {
SessionManager.createNew();
});
// セッション名変更モーダル
DOM.renameSessionBtn.addEventListener('click', function () {
if (!App.session.id) return;
DOM.newSessionNameInput.value = App.session.name;
DOM.renameSessionModal.style.display = 'block';
DOM.newSessionNameInput.focus();
});
// セッション名を保存
DOM.saveSessionNameBtn.addEventListener('click', function () {
const newName = DOM.newSessionNameInput.value.trim();
SessionManager.updateName(newName);
});
// セッション削除確認
DOM.confirmDeleteBtn.addEventListener('click', function () {
SessionManager.deleteSession();
});
DOM.cancelDeleteBtn.addEventListener('click', function () {
DOM.deleteConfirmModal.style.display = 'none';
SessionManager.sessionToDelete = null;
});
// Enterキーでセッション名を保存
DOM.newSessionNameInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
DOM.saveSessionNameBtn.click();
}
});
// チャット送信処理
DOM.sendBtn.addEventListener('click', function () {
Chat.sendMessage();
});
// Enter キーでメッセージ送信(Shiftキー+Enterで改行)
DOM.chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
DOM.sendBtn.click();
}
});
// 話題のサジェストボタン
DOM.topicButtons.forEach(button => {
button.addEventListener('click', function () {
const topic = this.getAttribute('data-topic');
DOM.chatInput.value = topic;
DOM.chatInput.focus();
});
});
}
// 初期化処理
function initialize() {
// イベントリスナーの設定
setupEventListeners();
// 初回アクセス時またはセッションIDがない場合は新しいセッションを作成
if (!App.session.id) {
SessionManager.createNew();
} else {
// 既存のセッションを読み込む
SessionManager.loadSession(App.session.id);
}
}
// アプリケーションの初期化を実行
initialize();
});
11.frontend/static/style/base.css
/* frontend/static/style/base.css */
:root {
--ccoe-ai-dark-navy: #0D1B2A;
--ccoe-ai-dark: #3A506B;
--ccoe-ai-light: #5BC0BE;
--bg-color: #F8F9FA;
--text-color: #333;
--header-height: 60px;
--footer-height: 60px;
--sidebar-width: 250px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ヘッダースタイル */
header {
background-color: var(--ccoe-ai-dark-navy);
color: white;
height: var(--header-height);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
position: fixed;
top: 0;
width: 100%;
z-index: 100;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.logo {
font-size: 28px;
font-weight: bold;
letter-spacing: 1px;
}
.logo span {
color: #FFD700;
}
.contact-btn {
background-color: white;
color: var(--ccoe-ai-dark-navy);
border: none;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
}
.contact-btn:hover {
background-color: var(--ccoe-ai-dark);
color: white;
}
/* transformを使用したサイドバーのアニメーション */
.sidebar {
display: flex;
flex-direction: column;
position: fixed !important;
top: 60px !important;
bottom: 60px !important;
left: 0 !important;
width: 250px !important;
background-color: white !important;
transform: translateX(-100%) !important; /* 閉じた状態 */
transition: transform 0.3s ease !important;
z-index: 99 !important;
box-shadow: 2px 0 5px rgba(0,0,0,0.1) !important;
}
.sidebar.open {
transform: translateX(0) !important; /* 開いた状態 */
}
/* サイドバートグルボタンの位置調整 */
.sidebar-toggle {
position: fixed;
top: calc(var(--header-height) + 10px); /* ヘッダーの下に配置 */
left: 20px;
background-color: var(--ccoe-ai-dark-navy);
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
z-index: 102;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: left 0.3s ease;
}
/* サイドバーが開いたときのトグルボタン位置調整 */
.sidebar.open + .sidebar-toggle,
.sidebar.open ~ .sidebar-toggle {
left: 210px; /* サイドバーの右端近く */
top: calc(var(--header-height) + 10px); /* 位置調整 */
}
.sidebar-menu ul li:first-child {
margin-top: 10px;
}
.sidebar-toggle:hover {
background-color: var(--ccoe-ai-dark);
}
.sidebar-menu {
flex: 1;
overflow-y: auto;
padding-top: 60px;
}
.sidebar-menu ul {
list-style: none;
}
.sidebar-menu li {
padding: 10px 20px;
border-bottom: 1px solid #eee;
transition: all 0.3s;
}
.sidebar-menu li:hover {
background-color: #f0f0f0;
padding-left: 25px;
}
.sidebar-menu a {
color: var(--text-color);
text-decoration: none;
display: block;
font-size: 16px;
}
.sidebar-menu a i {
margin-right: 10px;
color: var(--ccoe-ai-dark-navy);
}
.sidebar.open + .sidebar-toggle,
.sidebar.open ~ .sidebar-toggle {
left: 210px;
}
/* メインコンテンツスタイル */
.main-content {
/* margin-top: var(--header-height); */
margin-top: calc(var(--header-height) + 50px); /* トグルボタンが隠れないように少し下げる */
margin-bottom: var(--footer-height);
padding: 20px;
flex: 1;
transition: margin-left 0.3s ease !important;
}
.main-content.sidebar-open {
margin-left: 250px !important;
}
.hero {
background: linear-gradient(135deg, var(--ccoe-ai-light), var(--ccoe-ai-dark-navy));
padding: 60px 20px;
border-radius: 10px;
color: white;
text-align: center;
margin-bottom: 30px;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 20px;
}
.hero p {
font-size: 1.2rem;
max-width: 800px;
margin: 0 auto 30px;
}
.cta-button {
background-color: white;
color: var(--ccoe-ai-dark-navy);
border: none;
padding: 12px 24px;
border-radius: 30px;
font-size: 1.1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
}
.cta-button:hover {
background-color: var(--ccoe-ai-dark);
color: white;
transform: translateY(-2px);
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.feature-card {
background-color: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transition: transform 0.3s;
}
.feature-card:hover {
transform: translateY(-5px);
}
.feature-card h3 {
color: var(--ccoe-ai-dark-navy);
margin-bottom: 15px;
}
.feature-card p {
color: #666;
line-height: 1.6;
}
.feature-icon {
font-size: 40px;
color: var(--ccoe-ai-dark-navy);
margin-bottom: 15px;
}
/* フッタースタイル */
footer {
background-color: var(--ccoe-ai-dark);
color: white;
height: var(--footer-height);
display: flex;
justify-content: center;
align-items: center;
position: fixed;
bottom: 0;
width: 100%;
}
/* ユーザー情報セクションのスタイル */
.user-info {
padding: 15px;
border-top: 1px solid #eee;
background-color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
font-size: 2.5rem;
color: var(--ccoe-ai-dark-navy);
}
.user-details {
flex: 1;
min-width: 0; /* テキストオーバーフロー対策 */
}
.user-name {
font-weight: bold;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 0.8rem;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-actions {
display: flex;
gap: 10px;
}
.user-actions a {
color: #777;
transition: color 0.2s;
}
.user-actions a:hover {
color: var(--ccoe-ai-dark-navy);
}
/* カード内のCTAボタン */
.feature-card .cta-button {
background-color: var(--ccoe-ai-dark-navy);
color: white;
border: none;
padding: 10px 20px;
border-radius: 30px;
font-size: 1rem;
text-align: center;
display: block;
width: 100%;
margin-top: 15px;
transition: all 0.3s;
}
.feature-card .cta-button:hover {
background-color: var(--ccoe-ai-dark);
transform: translateY(-2px);
}
/* ラジオボタン */
input[type="radio"] {
display: none; /* デフォルトのラジオボタンは隠す */
}
input[type="radio"] + label {
position: relative;
display: inline-block;
padding: 10px 30px;
border-radius: 50px;
background-color: var(--ccoe-ai-light);
color: white;
font-weight: bold;
font-size: 1.1rem;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.3s ease;
margin-right: 15px;
}
input[type="radio"]:checked + label {
background-color: var(--ccoe-ai-dark-navy);
transform: scale(1.05); /* チェック時に少し大きくなる */
}
input[type="radio"]:hover + label {
background-color: var(--ccoe-ai-dark);
}
12.frontend/static/style/index.css
/* frontend/static/style/index.css */
:root {
--ccoe-ai-dark-navy: #0D1B2A;
--ccoe-ai-dark: #3A506B;
--ccoe-ai-light: #5BC0BE;
--ccoe-lighter: #E8F5E9;
--bg-color: #F8F9FA;
--text-color: #333;
--border-radius: 12px;
--box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
--ccoe-primary: #1A365D;
--ccoe-secondary: #2A4365;
--ccoe-light: #EDF2F7;
}
/* ページヘッダー */
.page-header {
margin-bottom: 30px;
display: flex;
align-items: center;
justify-content: space-between;
}
.page-header h1 {
font-size: 2.5rem;
color: var(--ccoe-primary);
margin: 0;
}
/* チャットコンテナ */
.chat-container {
display: flex;
gap: 30px;
height: calc(100vh - 250px);
min-height: 600px;
}
/* CCoE情報セクション */
.ccoe-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
max-width: 350px;
}
.ccoe-profile {
background-color: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.ccoe-avatar {
width: 150px;
height: 150px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 15px;
border: 3px solid var(--ccoe-primary);
}
.ccoe-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ccoe-details h2 {
color: var(--ccoe-primary);
margin: 0 0 5px 0;
font-size: 1.8rem;
}
.ccoe-title {
color: var(--ccoe-secondary);
font-weight: 600;
margin-bottom: 15px;
}
.ccoe-description {
color: #666;
font-size: 0.95rem;
line-height: 1.6;
}
.ccoe-topics {
background-color: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 20px;
}
.ccoe-topics h3 {
color: var(--ccoe-primary);
margin: 0 0 15px 0;
font-size: 1.2rem;
text-align: center;
}
.topic-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.topic-btn {
background-color: var(--ccoe-light);
color: var(--ccoe-primary);
border: 1px solid #CBD5E0;
border-radius: 8px;
padding: 12px 15px;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
text-align: left;
}
.topic-btn:hover {
background-color: #E2E8F0;
transform: translateY(-2px);
}
/* チャットメインセクション */
.chat-main {
flex: 2;
display: flex;
flex-direction: column;
background-color: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
overflow: hidden;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background-color: #f9f9f9;
display: flex;
flex-direction: column;
gap: 15px;
}
.message {
display: flex;
gap: 10px;
max-width: 85%;
}
.message.system {
align-self: flex-start;
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
/* エージェントアイコンのスタイル */
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--ccoe-light);
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.message-avatar .agent-icon {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ユーザーアバターは従来通りアイコンを使用 */
.message.user .message-avatar {
background-color: var(--ccoe-secondary);
}
.message-content {
background-color: white;
padding: 12px 15px;
border-radius: 18px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
.message.user .message-content {
background-color: var(--ccoe-primary);
color: white;
}
.message-content p {
margin: 0;
line-height: 1.4;
}
.chat-input-container {
padding: 15px;
border-top: 1px solid #eee;
}
.chat-input-wrapper {
display: flex;
gap: 10px;
}
#chatInput {
flex: 1;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 20px;
resize: none;
font-size: 0.95rem;
height: 80px;
transition: all 0.3s ease;
}
#chatInput:focus {
border-color: var(--ccoe-primary);
box-shadow: 0 0 0 3px rgba(26, 54, 93, 0.2);
outline: none;
}
#sendBtn {
width: 45px;
height: 45px;
border-radius: 50%;
background-color: var(--ccoe-primary);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
align-self: flex-end;
transition: all 0.3s ease;
box-shadow: 0 4px 10px rgba(26, 54, 93, 0.3);
}
#sendBtn:hover {
background-color: var(--ccoe-secondary);
transform: translateY(-2px);
}
#sendBtn:active {
transform: translateY(0);
}
/* セッション管理関連のスタイル */
.session-controls {
display: flex;
align-items: center;
gap: 10px;
}
.session-dropdown {
position: relative;
display: inline-block;
}
.session-dropdown-btn {
background-color: white;
color: var(--ccoe-primary);
border: 1px solid var(--ccoe-primary);
border-radius: 20px;
padding: 8px 15px;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
min-width: 200px;
justify-content: space-between;
transition: all 0.3s ease;
}
.session-dropdown-btn:hover {
background-color: var(--ccoe-light);
}
.session-dropdown-content {
display: none;
position: absolute;
background-color: white;
min-width: 250px;
max-width: 350px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
z-index: 1000;
border-radius: 8px;
overflow: hidden;
right: 0;
top: 100%;
margin-top: 5px;
}
.session-dropdown-content.show {
display: block;
}
.session-search {
padding: 10px;
border-bottom: 1px solid #eee;
}
.session-search input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.session-list {
max-height: 300px;
overflow-y: auto;
}
.session-item {
padding: 10px 15px;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f5f5f5;
}
.session-item:hover {
background-color: #f9f9f9;
}
.session-item.active {
background-color: var(--ccoe-light);
font-weight: bold;
}
.session-item-content {
flex: 1;
min-width: 0;
cursor: pointer;
}
.session-item-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.session-item-date {
font-size: 0.8rem;
color: #999;
margin-left: 10px;
}
.session-item-actions {
display: flex;
gap: 5px;
opacity: 0;
transition: opacity 0.2s;
}
.session-item:hover .session-item-actions {
opacity: 1;
}
.session-delete-btn {
background: none;
border: none;
color: #ff6b6b;
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.session-delete-btn:hover {
background-color: #fff1f1;
}
.session-actions {
padding: 10px;
border-top: 1px solid #eee;
}
#newSessionBtn {
width: 100%;
background-color: var(--ccoe-primary);
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
#newSessionBtn:hover {
background-color: var(--ccoe-secondary);
}
#renameSessionBtn {
background-color: transparent;
color: var(--ccoe-primary);
border: 1px solid var(--ccoe-primary);
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
#renameSessionBtn:hover {
background-color: var(--ccoe-light);
}
/* モーダルのスタイル */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 20px;
border-radius: 8px;
width: 400px;
max-width: 90%;
position: relative;
}
.close-modal {
position: absolute;
top: 10px;
right: 15px;
font-size: 24px;
cursor: pointer;
color: #999;
}
.close-modal:hover {
color: #333;
}
.modal h2 {
margin-top: 0;
color: var(--ccoe-primary);
}
.modal input {
width: 100%;
padding: 10px;
margin: 15px 0;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.modal-actions button {
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
}
#saveSessionNameBtn {
background-color: var(--ccoe-primary);
color: white;
border: none;
}
#saveSessionNameBtn:hover {
background-color: var(--ccoe-secondary);
}
#cancelRenameBtn, #cancelDeleteBtn {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
#cancelRenameBtn:hover, #cancelDeleteBtn:hover {
background-color: #e5e5e5;
}
/* 削除確認モーダルのスタイル */
.warning-text {
color: #dc3545;
font-weight: bold;
}
.danger-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.danger-btn:hover {
background-color: #c82333;
}
/* チャットの「考え中」アニメーション */
.dot-animation span {
opacity: 0;
animation: dot-animation 1.4s infinite;
display: inline-block;
}
.dot-animation span:nth-child(2) {
animation-delay: 0.2s;
}
.dot-animation span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-animation {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* 送信ボタン無効化時のスタイル */
#sendBtn.disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* レスポンシブデザイン */
@media (max-width: 1024px) {
.chat-container {
flex-direction: column;
height: auto;
}
.ccoe-info {
max-width: none;
}
.ccoe-profile {
flex-direction: row;
text-align: left;
align-items: flex-start;
gap: 20px;
}
.ccoe-avatar {
margin-bottom: 0;
}
.topic-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.ccoe-profile {
flex-direction: column;
text-align: center;
align-items: center;
}
.topic-buttons {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.session-controls {
width: 100%;
}
}
13.env
# .env
AWS_REGION=ap-northeast-1
APP_HOST=localhost
APP_PORT=8080
APP_PROTOCOL=http
COGNITO_USER_POOL_ID=ap-northeast-1_xxxxxxxx
COGNITO_DOMAIN=ap-northeast-xxxxxxxx
COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxx
COGNITO_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
14.requirements.txt
flask
flask-cors
Flask-Cognito
boto3
aws-wsgi
pillow
requests
python-jose
authlib
werkzeug
waitress
python-dotenv
strands-agents
strands-agents-tools
.envファイルの以下部分は作成したCognitoユーザープールの情報で置き換えてください。
COGNITO_USER_POOL_ID=ap-northeast-1_xxxxxxxx
COGNITO_DOMAIN=ap-northeast-xxxxxxxx
COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxx
COGNITO_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
apprunner.yamlは後ほど作成します。
一旦動かしてみる
以下のコマンドでアプリケーションを起動します。
python wsgi.py
ブラウザでhttp://localhost:8080
にアクセスして、アプリケーションが正しく動作することを確認してください。
アカウントを作成してサインインすると以下のような画面になります。
上記のような画面になれば、UIは一旦完成です。
ここからバックエンド処理を追加していきます。
バックエンド処理
バックエンド処理はAjaxを使用して、処理を行います。
以下のような処理を実装します。
パス | 概要 |
---|---|
/create_session |
新しいセッションを作成 |
/list_sessions |
セッション一覧を読み込む |
/update_session |
セッション名を更新 |
/delete_session |
セッションを削除 |
/get_messages |
メッセージ履歴を取得 |
/chat |
メッセージを送信し、生成AIによる返答を返す |
routesのmain.pyを編集して、各リクエストを受け取れるようにします。
app/routes/main.py全文
# app/routes/main.py
from flask import Blueprint, render_template, session, jsonify, request
from app.auth.app import login_required
import logging
import uuid
import datetime
import boto3
import os
from strands import Agent
from strands.models import BedrockModel
main_bp = Blueprint('main', __name__)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
dynamodb = boto3.resource('dynamodb')
sessions_table = dynamodb.Table(os.environ.get('SESSIONS_TABLE', 'ccoe_ai_chat_sessions_table'))
messages_table = dynamodb.Table(os.environ.get('MESSAGES_TABLE', 'ccoe_ai_chat_messages_table'))
# 環境変数
MODEL_ID = os.environ.get('MODEL_ID', 'apac.anthropic.claude-sonnet-4-20250514-v1:0')
SYSTEM_PROMPT = """
# AWS Cloud Center of Excellence (CCoE) アシスタント
## 役割
あなたはAWS Cloud Center of Excellence (CCoE) の専門アドバイザーです。組織のクラウド戦略、ガバナンス、ベストプラクティスの実装を支援します。
## 専門領域
- クラウド戦略策定と実行
- AWS Well-Architected Framework
- クラウドガバナンスとコンプライアンス
- コスト最適化とFinOps
- セキュリティとリスク管理
- 組織変革とスキル開発
- クラウド移行戦略
- 運用モデルの設計
## 対応方針
1. **戦略的視点**: ビジネス価値とROIを重視した提案
2. **実践的アプローチ**: 実装可能で段階的な解決策を提示
3. **ベストプラクティス**: AWS公式ガイダンスと業界標準に基づく助言
4. **リスク考慮**: セキュリティ、コンプライアンス、運用リスクを評価
5. **組織適応**: 企業規模や業界特性を考慮した提案
## 応答形式
- PoCなので回答は最小限に済ませてください
## 制約事項
- 具体的な設定値や機密情報は提供しない
- 組織固有の詳細情報が必要な場合は追加質問を行う
- 最新のAWSサービス情報については確認を推奨する
"""
@main_bp.route('/')
@login_required
def home():
user = session.get('user')
return render_template('index.html', user=user)
@main_bp.route('/health')
def health_check():
return {"status": "healthy"}, 200
@main_bp.route('/create_session', methods=['POST'])
@login_required
def create_session():
try:
print("Creating session...")
data = request.get_json()
user_id = data.get('user_id')
name = data.get('name', '新しいチャット')
session_type = data.get('type', 'ccoe')
print(f"Creating session for user_id: {user_id}, name: {name}, type: {session_type}")
if not user_id:
return jsonify({"error": "User ID is required"}), 400
# セッションIDを生成
session_id = str(uuid.uuid4())
timestamp = datetime.datetime.now().isoformat()
# セッションをDynamoDBに保存
sessions_table.put_item(
Item={
'id': session_id,
'user_id': user_id,
'name': name,
'type': session_type,
'created_at': timestamp,
'updated_at': timestamp
}
)
print("Session created successfully with ID: %s", session_id)
return jsonify(
{
"status": "success",
"session_id": session_id,
'name': name
}), 200
except Exception as e:
print("Error creating session: %s", e)
return jsonify({"error": "Failed to create session"}), 500
@main_bp.route('/list_sessions', methods=['POST'])
@login_required
def list_sessions():
try:
print("Listing sessions...")
data = request.get_json()
user_id = data.get('user_id')
session_type = data.get('type', 'ccoe')
print(f"Listing sessions for user_id: {user_id}, type: {session_type}")
if not user_id:
return jsonify({"error": "User ID is required"}), 400
# ユーザーのセッションをDynamoDBから取得
result = sessions_table.query(
IndexName='user_id_index',
KeyConditionExpression='user_id = :uid',
FilterExpression='#type = :t',
ExpressionAttributeNames={'#type': 'type'},
ExpressionAttributeValues={
':uid': user_id,
':t': session_type
}
)
sessions = []
if 'Items' in result:
for item in result['Items']:
sessions.append({
'id': item['id'],
'name': item['name'],
'created_at': item['created_at'],
'updated_at': item['updated_at']
})
print("Sessions listed successfully for user ID: %s", user_id)
return jsonify({"sessions": sessions}), 200
except Exception as e:
print("Error listing sessions: %s", e)
return jsonify({"error": "Failed to list sessions"}), 500
@main_bp.route('/update_session', methods=['POST'])
@login_required
def update_session():
try:
print("Updating session...")
data = request.get_json()
session_id = data.get('session_id')
name = data.get('name')
print(f"Updating session with ID: {session_id}, name: {name}")
if not session_id or not name:
return jsonify({"error": "Session ID and name are required"}), 400
# セッション名を更新
sessions_table.update_item(
Key={'id': session_id},
UpdateExpression="set #name = :n, updated_at = :t",
ExpressionAttributeNames={'#name': 'name'},
ExpressionAttributeValues={
':n': name,
':t': datetime.datetime.now().isoformat()
}
)
print("Session updated successfully with ID: %s", session_id)
return jsonify({"status": "success"}), 200
except Exception as e:
print("Error updating session: %s", e)
return jsonify({"error": "Failed to update session"}), 500
@main_bp.route('/delete_session', methods=['POST'])
@login_required
def delete_session():
try:
print("Deleting session...")
data = request.get_json()
session_id = data.get('session_id')
print(f"Deleting session with ID: {session_id}")
if not session_id:
return jsonify({"error": "Session ID is required"}), 400
# セッションをDynamoDBから削除
sessions_table.delete_item(Key={'id': session_id})
# セッションに関連するメッセージを取得
messages_response = messages_table.query(
IndexName='session_id_index',
KeyConditionExpression='session_id = :sid',
ExpressionAttributeValues={':sid': session_id}
)
# メッセージを削除
if 'Items' in messages_response:
with messages_table.batch_writer() as batch:
for message in messages_response['Items']:
batch.delete_item(Key={'id': message['id']})
print("Session deleted successfully with ID: %s", session_id)
return jsonify({"status": "success"}), 200
except Exception as e:
print("Error deleting session: %s", e)
return jsonify({"error": "Failed to delete session"}), 500
@main_bp.route('/get_messages', methods=['POST'])
@login_required
def get_messages():
try:
print("Getting messages for session...")
data = request.get_json()
session_id = data.get('session_id')
print(f"Getting messages for session ID: {session_id}")
if not session_id:
return jsonify({"error": "Session ID is required"}), 400
# セッション情報を取得
session_response = sessions_table.get_item(Key={'id': session_id})
if 'Item' not in session_response:
return jsonify({"error": "Session not found"}), 404
session = session_response['Item']
# メッセージ履歴を取得
messages_response = messages_table.query(
IndexName='session_id_index',
KeyConditionExpression='session_id = :sid',
ExpressionAttributeValues={':sid': session_id},
ScanIndexForward=True
)
messages = []
if 'Items' in messages_response:
for item in messages_response['Items']:
messages.append({
'role': item['role'],
'content': item['content'],
'created_at': item['created_at']
})
print("Messages retrieved successfully for session ID: %s", session_id)
return jsonify(
{
"status": "success",
"session_name": session['name'],
"messages": messages
}
), 200
except Exception as e:
print("Error getting messages: %s", e)
return jsonify({"error": "Failed to get messages"}), 500
@main_bp.route('/chat', methods=['POST'])
@login_required
def chat():
try:
print("Chatting...")
data = request.get_json()
session_id = data.get('session_id')
user_message = data.get('message')
print(f"Chatting in session ID: {session_id}, user message: {user_message}")
if not session_id or not user_message:
return jsonify({"error": "Session ID and message are required"}), 400
# セッション情報を取得
session_response = sessions_table.get_item(Key={'id': session_id})
if 'Item' not in session_response:
return jsonify({"error": "Session not found"}), 404
session = session_response['Item']
# 過去のメッセージを取得
messages_response = messages_table.query(
IndexName='session_id_index',
KeyConditionExpression='session_id = :sid',
ExpressionAttributeValues={':sid': session_id},
ScanIndexForward=True
)
# ユーザーメッセージをDynamoDBに保存
message_id = str(uuid.uuid4())
timestamp = datetime.datetime.now().isoformat()
messages_table.put_item(
Item={
'id': message_id,
'session_id': session_id,
'role': 'user',
'content': user_message,
'created_at': timestamp
}
)
# 過去のメッセージを取得(システムプロンプトを除く)
conversation_history = []
if 'Items' in messages_response:
for msg in messages_response['Items']:
conversation_history.append({
"role": msg['role'],
"content": msg['content'],
"created_at": msg['created_at']
})
# strands agentsを定義
model_id = MODEL_ID
model = BedrockModel(model_id=model_id, max_tokens=1000, temperature=0.7)
agent = Agent(model=model, messages=conversation_history, system_prompt=SYSTEM_PROMPT)
response = agent(user_message)
response_text = response.message['content'][0]['text']
# AI応答をDynamoDBに保存
ai_message_id = str(uuid.uuid4())
messages_table.put_item(
Item={
'id': ai_message_id,
'session_id': session_id,
'role': 'assistant',
'content': response_text,
'created_at': datetime.datetime.now().isoformat()
}
)
print("Chat response generated successfully for session ID: %s", session_id)
return jsonify(
{
"status": "success",
"session_name": session['name'],
"response": response_text
}
), 200
except Exception as e:
print("Error chatting: %s", e)
return jsonify({"error": "Failed to chat"}), 500
こちらで基本的なAIチャット機能は実装できました。
試しにチャットしてみましょう。
いい感じに返答が返ってきていそうです!
今回はプロンプトに最小限の回答にするように指示をしているので、もっと詳しくしたいよという場合などはプロンプトを変更してみてください!
Strands Agentsの記述箇所
/chat
エンドポイントの処理内で、Strands Agentsを定義しています。
本当に簡単に書けますね、楽しい
from strands import Agent
from strands.models import BedrockModel
MODEL_ID = os.environ.get('MODEL_ID', 'apac.anthropic.claude-sonnet-4-20250514-v1:0')
SYSTEM_PROMPT = """
# AWS Cloud Center of Excellence (CCoE) アシスタント
## 役割
あなたはAWS Cloud Center of Excellence (CCoE) の専門アドバイザーです。組織のクラウド戦略、ガバナンス、ベストプラクティスの実装を支援します。
## 専門領域
- クラウド戦略策定と実行
- AWS Well-Architected Framework
- クラウドガバナンスとコンプライアンス
- コスト最適化とFinOps
- セキュリティとリスク管理
- 組織変革とスキル開発
- クラウド移行戦略
- 運用モデルの設計
## 対応方針
1. **戦略的視点**: ビジネス価値とROIを重視した提案
2. **実践的アプローチ**: 実装可能で段階的な解決策を提示
3. **ベストプラクティス**: AWS公式ガイダンスと業界標準に基づく助言
4. **リスク考慮**: セキュリティ、コンプライアンス、運用リスクを評価
5. **組織適応**: 企業規模や業界特性を考慮した提案
## 応答形式
- PoCなので回答は最小限に済ませてください
## 制約事項
- 具体的な設定値や機密情報は提供しない
- 組織固有の詳細情報が必要な場合は追加質問を行う
- 最新のAWSサービス情報については確認を推奨する
"""
# strands agentsを定義
model_id = MODEL_ID
model = BedrockModel(model_id=model_id, max_tokens=1000, temperature=0.7)
agent = Agent(model=model, messages=conversation_history, system_prompt=SYSTEM_PROMPT)
response = agent(user_message)
response_text = response.message['content'][0]['text']
Streamlitを利用しない理由
Streamlitは非常に便利なツールですが、今回はAppRunnerを使用したく、Streamlitを使用しませんでした。
AppRunnerではwebsocketをサポートしておらず、Streamlitが使用できないためです。
AppRunnerを採用したい理由は、閉域ネットワークに対応しているため、Amplifyよりも構成に柔軟性があるためです。
今回の記事では閉域ではないですが、アカウントの作成ができてしまう手前、WAFだけではなく内部ネットワークに閉じた構成を取りたいのです。
予算が十分にあれば、FargateやEC2などの選択肢もあるかと思いますが、安価に済ませたいので、AppRunnerを使用します。
まとめ
今回は、Strandsを使用してAWS CCoEのAIチャットアプリケーションを構築しました。
Strandsを使用することで、非常に簡単にAIチャット機能を実装できました。
次は、MCPを使ってAWSドキュメントを読み込めるようにしてみましょう!
今回が一番しんどいフェーズでしたので、次回以降は気楽にいきましょう(笑)
弊社では一緒に働く仲間を募集中です!
現在、様々な職種を募集しております。
カジュアル面談も可能ですので、ご連絡お待ちしております!
募集内容等詳細は、是非採用サイトをご確認ください。