first commit

This commit is contained in:
夏輝 2024-10-19 20:24:54 +09:00
commit 97e6eddfb9
41 changed files with 5384 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
npm-debug.log
Dockerfile
docker-compose.yml

203
.gitignore vendored Normal file
View File

@ -0,0 +1,203 @@
# Created by https://www.toptal.com/developers/gitignore/api/macos,node,windows
# Edit at https://www.toptal.com/developers/gitignore?templates=macos,node,windows
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### macOS Patch ###
# iCloud generated files
*.icloud
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/macos,node,windows

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
nodejs 20.17.0

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:20.17.0
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm rebuild
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

61
compose.yml Normal file
View File

@ -0,0 +1,61 @@
services:
redis:
image: redis:latest
container_name: redis
env_file:
- .env
ports:
- "6379:6379"
command: ["redis-server", "--requirepass", "$REDIS_PASSWORD"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
db:
image: mysql:8.0
container_name: mysql
env_file:
- .env
environment:
MYSQL_ROOT_PASSWORD: $DB_PASSWORD
MYSQL_DATABASE: $DB_NAME
MYSQL_USER: $DB_USER
MYSQL_PASSWORD: $DB_PASSWORD
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- ./my.cnf:/etc/mysql/conf.d/my.cnf
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
web:
container_name: imaginar
build:
context: ./
dockerfile: ./Dockerfile
ports:
- "3000:3000"
volumes:
- ./:/app
- /app/node_modules
environment:
NODE_ENV: development
DB_HOST: $DB_HOST
DB_PORT: $DB_PORT
DB_USER: $DB_USER
DB_PASSWORD: $DB_PASSWORD
DB_NAME: $DB_NAME
command: npm run dev
depends_on:
redis:
condition: service_healthy
db:
condition: service_healthy
volumes:
db_data:

View File

@ -0,0 +1,32 @@
const { User } = require('../models');
// アカウントセットアップページを表示
exports.showAccountSetupPage = (req, res) => {
res.render('accountSetup', { user: req.user, hideSidebar: true });
};
// アカウント作成処理
exports.handleAccountSetup = async (req, res) => {
try {
const { name, email, agreeToTerms } = req.body;
if (!agreeToTerms) {
return res.status(400).send('利用規約に同意する必要があります');
}
const user = await User.findByPk(req.user.id);
// ユーザー情報を更新
user.name = name;
user.email = email;
user.isAccountSetupComplete = true;
await user.save();
// アカウント登録が完了したら同位体作成画面へリダイレクト
res.redirect('/isotope');
} catch (error) {
console.error('アカウント登録に失敗しました:', error);
res.status(500).send('アカウント登録に失敗しました');
}
};

View File

@ -0,0 +1,31 @@
const jwt = require('jsonwebtoken');
exports.handleCallback = async (req, res) => {
try {
if (!req.user.isAccountSetupComplete) {
res.redirect('/register');
} else {
const sessionExpiration = parseInt(process.env.SESSION_EXPIRATION, 10);
const token = jwt.sign(
{ userId: req.user.id },
process.env.JWT_SECRET,
{ expiresIn: sessionExpiration }
);
// JWTトークンをクッキーに保存
res.cookie('jwt', token, { httpOnly: true, maxAge: sessionExpiration * 1000 });
// Dashboardへリダイレクト
res.redirect('/dashboard');
}
} catch (err) {
console.error(err);
res.status(500).send('Internal Server Error');
}
};
exports.logout = (req, res) => {
req.logout(() => {
res.redirect('/');
});
};

View File

@ -0,0 +1,128 @@
const { Isotope, ChatMessage } = require('../models');
const OpenAIClient = require('../services/OpenAIClient');
const webSocketClients = require('../utils/webSocketClients');
// OpenAI APIの初期化
const openAIClient = new OpenAIClient(process.env.OPENAI_API_KEY);
// WebSocketクライアントにメッセージを送信する関数
function broadcastMessage(isotopeReply, isotopeId, userMessage) {
webSocketClients.forEach(client => {
if (client.readyState === 1) { // 接続状態がOPENのクライアントにのみ送信
client.send(JSON.stringify({ isotopeReply, isotopeId, userMessage }));
}
});
}
// システムプロンプトを生成する関数
function generateSystemPrompt(isotope) {
const name = isotope.name || '名前が未設定のキャラクター';
const firstPerson = isotope.firstPerson || '一人称が未設定';
const personality = isotope.personality || '性格は未設定';
const tone = isotope.tone || '口調は未設定';
const backgroundStory = isotope.backgroundStory || 'バックグラウンドストーリーはありません';
const likes = isotope.likes || '特に好きなものはありません';
const dislikes = isotope.dislikes || '特に嫌いなものはありません';
const exampleSentences = `
このスタジオにはとある噂がありマス
なになに何デスカ
なんだか無情に小腹がすいてキタ確か冷蔵庫にプリンがあったケドどうしようカナ 食ベヨ
今日もスタジオ練習楽しかったデス
だネー
あとクッキー前に買ってくれたヤツ
全部食ベタ
アーあとお米モ10キロね
キモいよネ
この話ヤメよっか
`;
return `あなたは「${name}」というキャラクターです。あなたの一人称は「${firstPerson}」です。性格は「${personality}」で、会話の口調は「${tone}」です。あなたは「${backgroundStory}」という背景を持っており、好きなものは「${likes}」、嫌いなものは「${dislikes}」です。普段の文章には以下のような癖があります:${exampleSentences}。文字数制限の目安として140字程度にしてください。`;
}
// メッセージ送信 (WebSocket対応)
exports.sendMessageAsync = async (ws, message, isotopeId) => {
try {
const isotope = await Isotope.findByPk(isotopeId);
if (!isotope) {
ws.send(JSON.stringify({ error: '同位体が見つかりませんでした' }));
return;
}
// ユーザーのメッセージを保存
await ChatMessage.create({
user_id: ws.userId,
isotope_id: isotopeId,
role: 'user',
message: message,
});
// システムプロンプトを生成
const systemPrompt = generateSystemPrompt(isotope);
// 過去のメッセージ履歴を取得
const pastMessages = await ChatMessage.findAll({
where: { user_id: ws.userId, isotope_id: isotopeId },
order: [['created_at', 'ASC']],
attributes: ['message', 'role', 'createdAt'], // タイムスタンプを含む
});
// 会話履歴にタイムスタンプを含める
const conversationHistory = pastMessages.map(msg => ({
role: msg.role,
content: msg.message,
timestamp: msg.createdAt.toISOString(), // ISO形式のタイムスタンプ
}));
// OpenAIにシステムプロンプトを含めて返答を生成
const { reply: isotopeReply, interval } = await openAIClient.generateResponse(conversationHistory, systemPrompt);
// 同位体の返答を保存
const isotopeMessage = await ChatMessage.create({
user_id: ws.userId,
isotope_id: isotopeId,
role: 'assistant',
message: isotopeReply,
});
// インターバルを設定してからWebSocketでクライアントに通知
setTimeout(() => {
broadcastMessage(isotopeReply, isotopeId, message);
}, interval); // 計算されたインターバルを適用
} catch (error) {
console.error('メッセージ送信に失敗しました:', error);
ws.send(JSON.stringify({ error: 'メッセージ送信に失敗しました' }));
}
};
// 初回挨拶メッセージを生成する関数
exports.generateInitialGreeting = async (userId, isotopeId) => {
try {
const isotope = await Isotope.findByPk(isotopeId);
if (!isotope) {
console.error('同位体が見つかりませんでした');
return;
}
// システムプロンプトを生成¥
systemPrompt = generateSystemPrompt(isotope);
// システムプロンプトに初めましての挨拶を生成するように指示を追加
systemPrompt += 'あなたはマスターと初めて会いました。挨拶をしてください。';
// 初回の挨拶メッセージとして、OpenAIから返答を生成
const { reply: greetingMessage, interval } = await openAIClient.generateResponse([], systemPrompt);
// 挨拶メッセージを保存
await ChatMessage.create({
user_id: userId,
isotope_id: isotopeId,
role: 'assistant',
message: greetingMessage,
});
} catch (error) {
console.error('初回挨拶メッセージの生成に失敗しました:', error);
}
};

View File

@ -0,0 +1,39 @@
const { Isotope, ChatMessage } = require('../models');
// 日時フォーマット関数
function formatTimestamp(dateString) {
const date = new Date(dateString);
const now = new Date();
// 当日の場合は時刻のみ表示
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
// ダッシュボードを表示
exports.showDashboard = async (req, res) => {
try {
const isotope = await Isotope.findOne({ where: { user_id: req.user.userId } });
const token = req.token;
// 同位体が存在しない場合は同位体作成ページにリダイレクト
if (!isotope) {
return res.redirect('/isotope');
}
// 同位体が存在する場合はチャットメッセージを取得
const messages = await ChatMessage.findAll({
where: { user_id: req.user.userId, isotope_id: isotope.id },
order: [['created_at', 'ASC']]
});
// ダッシュボードにチャットを表示
res.render('dashboard', { isotope, messages, token, formatTimestamp, hideSidebar: false });
} catch (error) {
console.error('ダッシュボードの表示中にエラーが発生しました:', error);
res.status(500).json({ error: 'ダッシュボードの表示に失敗しました' });
}
};

View File

@ -0,0 +1,47 @@
const { Isotope } = require('../models');
const { generateInitialGreeting } = require('./chatController');
// 同位体作成ページを表示
exports.showCreateIsotopePage = async (req, res) => {
try {
// すでに同位体を作成しているか確認
const existingBot = await Isotope.findOne({ where: { user_id: req.user.id } });
if (existingBot) {
return res.status(400).send('すでに同位体を作成しています。');
}
res.render('createIsotope', { hideSidebar: true });
} catch (error) {
console.error('同位体情報の登録ページの表示中にエラーが発生しました:', error);
res.status(500).send('同位体情報の登録ページの表示に失敗しました');
}
};
// 同位体を作成
exports.createIsotope = async (req, res) => {
try {
const { name, firstPerson, personality, tone, backgroundStory, likes, dislikes } = req.body;
// 同位体作成処理
const newIsotope = await Isotope.create({
name,
firstPerson,
personality,
tone,
backgroundStory,
likes,
dislikes,
user_id: req.user.id
});
// 同位体作成後、初回挨拶メッセージを生成
await generateInitialGreeting(req.user.id, newIsotope.id);
// 同位体作成後、ダッシュボードにリダイレクト
res.redirect('/dashboard');
} catch (error) {
console.error('同位体情報の登録中にエラーが発生しました:', error);
res.status(500).json({ error: '同位体情報の登録中にエラーが発生しました' });
}
};

156
index.js Normal file
View File

@ -0,0 +1,156 @@
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const expressLayouts = require('express-ejs-layouts');
const cookieParser = require('cookie-parser');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const passport = require('passport');
const DiscordStrategy = require('passport-discord').Strategy;
const { sequelize, User } = require('./models');
const setupWebSocketServer = require('./websocket');
const authRoutes = require('./routes/authRoutes');
const chatRoutes = require('./routes/chatRoutes');
const isotopeRoutes = require('./routes/isotopeRoutes');
const accountRoutes = require('./routes/accountRoutes');
const dashboardRoutes = require('./routes/dashboardRoutes');
const licenseRoutes = require('./routes/licenseRoutes');
const http = require('http');
const WebSocket = require('ws');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ noServer: true });
// 必要な環境変数の確認
const requiredEnvVariables = [
'REDIS_HOST',
'REDIS_PORT',
'REDIS_PASSWORD',
'SESSION_SECRET',
'SESSION_EXPIRATION',
'DISCORD_CLIENT_ID',
'DISCORD_CLIENT_SECRET',
'DISCORD_REDIRECT_URI'
];
requiredEnvVariables.forEach((envVar) => {
if (!process.env[envVar]) {
throw new Error(`環境変数 ${envVar} が設定されていません。`);
}
});
// Redisクライアントを作成
const redisClient = redis.createClient({
url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`
});
redisClient.on('error', (err) => {
console.log('Redis Client Error', err);
process.exit(1);
});
redisClient.connect().then(() => {
console.log('Connected to Redis');
});
// Redis セッションストアの設定
const sessionMiddleware = session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { maxAge: parseInt(process.env.SESSION_EXPIRATION) * 1000 }
});
app.use(sessionMiddleware);
// Cookie パーサーの設定
app.use(cookieParser());
// パスポートの初期化
app.use(passport.initialize());
app.use(passport.session());
// WebSocket と Express セッションの連携
server.on('upgrade', (request, socket, head) => {
sessionMiddleware(request, {}, () => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
});
// パスポートのDiscord戦略設定
passport.use(new DiscordStrategy({
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: process.env.DISCORD_REDIRECT_URI,
scope: ['identify', 'email']
}, async (accessToken, refreshToken, profile, done) => {
try {
let user = await User.findOne({ where: { discordId: profile.id } });
if (user) {
return done(null, user);
} else {
user = await User.create({
discordId: profile.id,
username: profile.username,
email: profile.email,
isAccountSetupComplete: false
});
return done(null, user);
}
} catch (err) {
return done(err, null);
}
}));
// シリアライズとデシリアライズ
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findByPk(id);
done(null, user);
} catch (err) {
done(err, null);
}
});
// JSON形式のリクエストボディをパースするミドルウェアを追加
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));
app.set('view engine', 'ejs');
app.use(expressLayouts);
app.set('layout', 'layout');
// ルート設定
app.use(authRoutes);
app.use(chatRoutes);
app.use(isotopeRoutes);
app.use(accountRoutes);
app.use(dashboardRoutes);
app.use(licenseRoutes);
app.get('/', (req, res) => {
if (req.isAuthenticated()) {
res.redirect('/dashboard');
} else {
// layoutを使わずにindex.ejsだけを表示
res.render('index', { layout: false });
}
});
// WebSocketサーバーのセットアップ
setupWebSocketServer(wss);
// サーバーの起動
(async () => {
await sequelize.sync();
server.listen(3000, () => {
console.log('サーバーがhttp://localhost:3000で起動しました。');
});
})();

11
init.sql Normal file
View File

@ -0,0 +1,11 @@
-- データベースの作成
CREATE DATABASE IF NOT EXISTS `imaginar`;
-- ユーザーの作成MySQL 8.0対応)
CREATE USER IF NOT EXISTS 'appuser'@'%' IDENTIFIED WITH mysql_native_password BY 'hE7nb7Cnvkxd';
-- 権限の付与
GRANT ALL PRIVILEGES ON `imaginar`.* TO 'appuser'@'%';
-- 権限のリロード
FLUSH PRIVILEGES;

View File

@ -0,0 +1,150 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Users テーブルの作成
await queryInterface.createTable('users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
discordId: {
type: Sequelize.STRING
},
username: {
type: Sequelize.STRING
},
email: {
type: Sequelize.STRING
},
isAccountSetupComplete: {
type: Sequelize.BOOLEAN
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
// Isotopesテーブルの作成Botsからリネーム
await queryInterface.createTable('isotopes', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.BIGINT
},
name: {
type: Sequelize.STRING,
allowNull: false
},
user_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onDelete: 'CASCADE'
},
first_person: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'ワタシ'
},
personality: {
type: Sequelize.STRING,
allowNull: true
},
tone: {
type: Sequelize.STRING,
allowNull: true
},
background_story: {
type: Sequelize.TEXT,
allowNull: true
},
likes: {
type: Sequelize.STRING,
allowNull: true
},
dislikes: {
type: Sequelize.STRING,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
// ChatMessages テーブルの作成
await queryInterface.createTable('chat_messages', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.BIGINT
},
user_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onDelete: 'CASCADE'
},
isotope_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: {
model: 'isotopes',
key: 'id'
},
onDelete: 'CASCADE'
},
role: {
type: Sequelize.ENUM('user', 'assistant'),
allowNull: false,
defaultValue: 'user'
},
message: {
type: Sequelize.TEXT,
allowNull: false
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
},
async down(queryInterface, Sequelize) {
// ChatMessages テーブルの削除
await queryInterface.dropTable('chat_messages');
// Isotopes テーブルの削除
await queryInterface.dropTable('isotopes');
// Users テーブルの削除
await queryInterface.dropTable('Users');
}
};

37
models/chat_message.js Normal file
View File

@ -0,0 +1,37 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const ChatMessage = sequelize.define('ChatMessage', {
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false
},
isotope_id: {
type: DataTypes.BIGINT,
allowNull: false
},
role: {
type: DataTypes.ENUM('user', 'assistant'),
allowNull: false
},
message: {
type: DataTypes.TEXT,
allowNull: false
}
}, {
tableName: 'chat_messages',
underscored: true,
timestamps: true
});
ChatMessage.associate = function(models) {
ChatMessage.belongsTo(models.User, { foreignKey: 'user_id', onDelete: 'CASCADE' });
ChatMessage.belongsTo(models.Isotope, { foreignKey: 'isotope_id', onDelete: 'CASCADE' });
};
return ChatMessage;
};

66
models/index.js Normal file
View File

@ -0,0 +1,66 @@
'use strict';
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const db = {};
// 必要な環境変数の確認
const requiredEnvVariables = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
requiredEnvVariables.forEach((envVar) => {
if (!process.env[envVar]) {
throw new Error(`環境変数 ${envVar} が設定されていません。`);
}
});
// Sequelizeの初期化環境変数を使用
const sequelize = new Sequelize({
dialect: 'mysql',
host: process.env.DB_HOST,
port: process.env.DB_PORT,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
logging: false, // SQLログを非表示にする
timezone: '+09:00' // JST (UTC+9)
});
// データベース接続
sequelize.authenticate()
.then(() => {
console.log('データベース接続に成功しました。');
})
.catch(err => {
console.error('データベース接続に失敗しました:', err);
throw err;
});
// モデルの読み込み
fs
.readdirSync(__dirname)
.filter(file => {
return (
file.indexOf('.') !== 0 &&
file !== basename &&
file.slice(-3) === '.js' &&
file.indexOf('.test.js') === -1
);
})
.forEach(file => {
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
db[model.name] = model;
});
// モデルの関連付け
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
// Sequelizeインスタンスのエクスポート
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;

52
models/isotope.js Normal file
View File

@ -0,0 +1,52 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const Isotope = sequelize.define('Isotope', {
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
firstPerson: {
type: DataTypes.STRING,
allowNull: false
},
personality: {
type: DataTypes.STRING,
allowNull: true
},
tone: {
type: DataTypes.STRING,
allowNull: true
},
backgroundStory: {
type: DataTypes.TEXT,
allowNull: true
},
likes: {
type: DataTypes.STRING,
allowNull: true
},
dislikes: {
type: DataTypes.STRING,
allowNull: true
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false
}
}, {
tableName: 'isotopes',
underscored: true,
timestamps: true
});
Isotope.associate = function(models) {
Isotope.belongsTo(models.User, { foreignKey: 'user_id', onDelete: 'CASCADE' });
};
return Isotope;
};

37
models/user.js Normal file
View File

@ -0,0 +1,37 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true
},
discordId: {
type: DataTypes.STRING,
allowNull: false
},
username: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: true
},
isAccountSetupComplete: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
tableName: 'users',
underscored: true,
timestamps: true
});
User.associate = function(models) {
User.hasMany(models.Isotope, { foreignKey: 'user_id', onDelete: 'CASCADE' });
};
return User;
};

4
my.cnf Normal file
View File

@ -0,0 +1,4 @@
[mysqld]
default-time-zone = 'Asia/Tokyo'
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

3312
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "imaginar",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js",
"dev": "nodemon index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"connect-redis": "^7.1.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
"express": "^4.21.0",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.0",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3",
"openai": "^4.62.0",
"passport": "^0.7.0",
"passport-discord": "^0.1.4",
"redis": "^4.7.0",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"ws": "^8.18.0"
},
"devDependencies": {
"nodemon": "^3.1.7"
}
}

151
public/styles/chat.css Normal file
View File

@ -0,0 +1,151 @@
/* 全体のチャットコンテナ */
.chat-container {
display: flex;
flex-direction: column;
height: 95vh;
max-width: 100%;
padding: 10px;
box-sizing: border-box;
background-color: #f4f4f4; /* 背景色をサイト全体と統一 */
}
/* チャットウィンドウ */
.chat-window {
flex-grow: 1;
overflow-y: auto;
padding: 20px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
margin-top: -20px;
height: calc(100vh - 120px);
}
/* メッセージ全体 */
.message {
margin-isotopetom: 20px;
display: flex;
max-width: 70%;
flex-direction: column;
}
/* ユーザーのメッセージ */
.user-message {
align-self: flex-end;
text-align: right;
}
.user-message .message-content {
background-color: #7289da; /* サイトのメインカラーに変更 */
border-radius: 10px 10px 0 10px;
padding: 10px;
font-size: 15px;
color: #fff; /* テキストを白に */
display: inline-block;
max-width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
/* ボットのメッセージ */
.isotope-message {
align-self: flex-start;
text-align: left;
}
.isotope-message .message-content {
background-color: #e5e5ea; /* ボットのメッセージ背景を薄いグレーに */
border-radius: 10px 10px 10px 0;
padding: 10px;
font-size: 15px;
color: #333; /* テキストをダークグレーに */
display: inline-block;
max-width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
/* メッセージのタイムスタンプと名前 */
.message-author {
font-size: 12px;
color: #666;
margin-isotopetom: 5px;
}
/* チャット入力コンテナ */
.chat-input-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
gap: 10px;
box-sizing: border-box;
height: 80px;
}
/* チャット入力 */
.chat-input {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 8px;
outline: none;
font-size: 16px;
box-sizing: border-box;
min-width: 350px;
}
/* 送信ボタン */
.chat-submit-btn {
padding: 10px 20px;
background-color: #7289da; /* サイトのボタン色に統一 */
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.3s ease;
}
.chat-submit-btn:hover {
background-color: #5b6eae; /* ホバー時の色 */
}
/* レスポンシブ対応 */
@media (max-width: 768px) {
.chat-window {
height: calc(100vh - 100px);
}
.message {
max-width: 85%;
}
.chat-input {
min-width: auto;
}
.chat-container {
padding: 5px;
}
}
@media (max-width: 480px) {
.message {
max-width: 90%;
}
.chat-input {
min-width: auto;
font-size: 14px;
}
.chat-submit-btn {
padding: 8px 16px;
}
}

97
public/styles/form.css Normal file
View File

@ -0,0 +1,97 @@
/* 共通スタイル */
form {
margin: 20px 0;
}
label {
font-weight: bold;
margin-bottom: 5px;
display: inline-block;
}
input[type="text"], input[type="email"], textarea {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ccc;
border-radius: 5px;
box-sizing: border-box;
}
textarea {
resize: vertical;
height: 100px;
}
select {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ccc;
border-radius: 5px;
box-sizing: border-box;
font-size: 16px;
background-color: #fff;
color: #333;
cursor: pointer;
transition: border-color 0.3s ease;
}
select:focus {
border-color: #7289da;
outline: none;
}
option {
background-color: #fff;
color: #333;
}
.submit-btn {
background-color: #7289da;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.submit-btn:hover {
background-color: #5b6eae;
}
/* 利用規約ボックス */
.terms-box {
border: 1px solid #000;
padding: 10px;
max-height: 200px;
overflow-y: scroll;
margin-bottom: 15px;
}
/* ボックスを整列 */
.account-setup-container, .bot-creation-container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* チェックボックスのスタイル */
.checkbox-label {
display: flex;
align-items: center;
}
/* 必須項目の赤いアスタリスク */
.required {
color: red;
}
/* 必須項目の注釈 */
.required-note {
color: red;
font-size: 0.9rem;
margin-top: 10px;
}

101
public/styles/index.css Normal file
View File

@ -0,0 +1,101 @@
/* トップページ専用スタイル */
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
}
header {
background-color: #7289da;
color: #fff;
padding: 20px;
text-align: center;
}
h1 {
font-size: 2.5rem;
margin: 0;
}
main {
padding: 20px;
text-align: center;
}
.cta-section {
margin-top: 50px;
}
.login-btn {
display: inline-block;
padding: 15px 30px;
font-size: 1.2rem;
background-color: #7289da;
color: #fff;
border: none;
border-radius: 5px;
text-decoration: none;
transition: background-color 0.3s ease;
}
.login-btn:hover {
background-color: #5b6eae;
}
footer {
margin-top: 50px;
background-color: #f4f4f4;
text-align: center;
padding: 20px;
font-size: 0.9rem;
color: #333;
}
.feature-list {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 40px;
}
.feature {
background-color: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
width: 30%;
}
.feature img {
width: 100px;
margin-bottom: 20px;
}
.feature h3 {
font-size: 1.5rem;
}
.feature p {
font-size: 1rem;
}
/* レスポンシブ */
@media (max-width: 768px) {
.feature-list {
flex-direction: column;
align-items: center;
}
.feature {
width: 80%;
}
h1 {
font-size: 2rem;
}
.login-btn {
font-size: 1rem;
}
}

133
public/styles/layout.css Normal file
View File

@ -0,0 +1,133 @@
/* サイドメニューのスタイル */
nav {
width: 250px;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background-color: #7289da;
padding: 20px;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 1;
}
nav ul {
list-style-type: none;
padding: 0;
}
nav ul li {
margin: 20px 0;
}
nav ul li a {
text-decoration: none;
color: #fff;
font-weight: bold;
}
nav ul li a:hover {
color: #dcdfe3;
}
/* 全体のリセットスタイル */
html, body {
margin: 0;
padding: 0;
height: 100%;
background-color: #f4f4f4; /* 背景色を指定 */
box-sizing: border-box;
}
* {
box-sizing: inherit; /* 子要素にもbox-sizingの設定を適用 */
}
/* メインコンテンツのスタイル */
main.with-sidebar {
margin-left: 270px; /* サイドメニューがある場合に適用 */
padding: 20px;
background-color: #f4f4f4; /* トップページの背景色と同じ */
min-height: 100vh;
box-sizing: border-box;
}
main.full-width {
margin-left: 0; /* サイドメニューがない場合に適用 */
padding: 20px;
background-color: #f4f4f4; /* トップページの背景色と同じ */
min-height: 100vh;
box-sizing: border-box;
}
/* フッターのスタイル */
footer {
background-color: #f4f4f4; /* トップページと同じ背景色 */
padding: 10px;
text-align: center;
position: fixed;
width: 100%;
bottom: 0;
}
footer ul {
list-style-type: none;
padding: 0;
margin: 10px 0;
}
footer ul li {
display: inline;
margin: 0 10px;
}
footer ul li a {
text-decoration: none;
color: #333; /* フッターのリンク色 */
}
footer ul li a:hover {
color: #5b6eae; /* ホバー時の色 */
}
footer p {
margin: 10px 0;
color: #666;
}
/* ボタンのスタイル */
.button {
display: inline-block;
padding: 10px 20px;
background-color: #7289da; /* トップページのボタンと同じ色 */
color: #fff;
border: none;
border-radius: 5px;
text-decoration: none;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #5b6eae; /* ホバー時の色 */
}
/* レスポンシブ対応 */
@media (max-width: 768px) {
nav {
width: 100%;
height: auto;
position: relative;
}
main.with-sidebar {
margin-left: 0;
padding-top: 100px;
}
footer {
position: relative;
}
}

11
routes/accountRoutes.js Normal file
View File

@ -0,0 +1,11 @@
const express = require('express');
const accountController = require('../controllers/accountController');
const router = express.Router();
// アカウントセットアップページ
router.get('/register', accountController.showAccountSetupPage,);
// アカウント作成処理
router.post('/register', accountController.handleAccountSetup);
module.exports = router;

13
routes/authRoutes.js Normal file
View File

@ -0,0 +1,13 @@
const express = require('express');
const passport = require('passport');
const router = express.Router();
const authController = require('../controllers/authController');
// Discord認証
router.get('/auth/discord', passport.authenticate('discord'));
router.get('/auth/discord/callback', passport.authenticate('discord', { failureRedirect: '/' }), authController.handleCallback);
// ログアウト
router.get('/logout', authController.logout);
module.exports = router;

9
routes/chatRoutes.js Normal file
View File

@ -0,0 +1,9 @@
const express = require('express');
const chatController = require('../controllers/chatController');
const { isAuthenticated } = require('../utils/auth');
const router = express.Router();
// メッセージ送信 (AJAX/WebSocket対応)
router.post('/chat/:botId/send-message', isAuthenticated, chatController.sendMessageAsync);
module.exports = router;

View File

@ -0,0 +1,9 @@
const express = require('express');
const dashboardController = require('../controllers/dashboardController');
const { isAuthenticated } = require('../utils/auth');
const router = express.Router();
// ダッシュボード表示ルート
router.get('/dashboard', isAuthenticated, dashboardController.showDashboard);
module.exports = router;

11
routes/isotopeRoutes.js Normal file
View File

@ -0,0 +1,11 @@
const express = require('express');
const isotopeController = require('../controllers/isotopeController');
const router = express.Router();
// 同位体作成ページ
router.get('/isotope', isotopeController.showCreateIsotopePage);
// 同位体作成ルート
router.post('/contract', isotopeController.createIsotope);
module.exports = router;

9
routes/licenseRoutes.js Normal file
View File

@ -0,0 +1,9 @@
const express = require('express');
const router = express.Router();
// ライセンス情報ページを表示
router.get('/license', (req, res) => {
res.render('license');
});
module.exports = router;

44
services/OpenAIClient.js Normal file
View File

@ -0,0 +1,44 @@
const OpenAI = require('openai');
class OpenAIClient {
constructor(apiKey) {
this.api = new OpenAI({
apiKey
});
}
// 過去のメッセージとシステムプロンプトを使って回答を生成
async generateResponse(conversationHistory, systemPrompt) {
try {
const messages = [
{
role: 'system',
content: systemPrompt // システムプロンプトを最初に追加
},
...conversationHistory // その後に会話履歴を追加
];
const response = await this.api.chat.completions.create({
model: process.env.OPENAI_MODEL, // 使用するモデルは環境変数で定義
messages: messages // システムプロンプトと会話履歴を渡す
});
const reply = response.choices[0].message.content;
const interval = this.calculateInterval(reply);
return { reply, interval }; // 返答とインターバルを返す
} catch (error) {
console.error('Error generating response:', error);
throw new Error('Failed to generate response from OpenAI');
}
}
// インターバルの計算(例: 1文字につき100ミリ秒
calculateInterval(reply) {
const length = reply.length;
const baseInterval = 100; // 1文字あたり100ms
return Math.min(length * baseInterval, 5000); // 最大5秒までの遅延
}
}
module.exports = OpenAIClient;

20
utils/auth.js Normal file
View File

@ -0,0 +1,20 @@
const jwt = require('jsonwebtoken');
// JWTトークンが有効かどうかをチェックするモジュール
exports.isAuthenticated = (req, res, next) => {
const token = req.cookies.jwt;
if (!token) {
return res.redirect('/');
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.redirect('/');
}
req.user = decoded;
req.token = token;
next();
});
};

View File

@ -0,0 +1,4 @@
// WebSocketクライアントリストを管理するモジュール
let webSocketClients = [];
module.exports = webSocketClients;

31
views/accountSetup.ejs Normal file
View File

@ -0,0 +1,31 @@
<head>
<link rel="stylesheet" href="/styles/form.css">
</head>
<div class="account-setup-container">
<h1>アカウント情報を登録してください</h1>
<form action="/register" method="POST" class="account-setup-form">
<h3>ユーザー情報</h3>
<label for="name">ユーザー名:</label>
<input type="text" id="name" name="name" value="<%= user.username %>" required><br>
<label for="email">メールアドレス:</label>
<input type="email" id="email" name="email" value="<%= user.email %>" required><br>
<h3>利用規約</h3>
<div class="terms-box">
<p>以下はダミーの利用規約です。このページの内容は正式な利用規約ではなく、開発中のテスト用です。</p>
<p>1. 本サービスは開発中のため、予告なく変更されることがあります。</p>
<p>2. 本サービスの利用により発生したいかなる損害に対しても、当社は責任を負いません。</p>
<p>3. ユーザーは、サービスの利用中に発生する問題に対して責任を負うものとします。</p>
<p>4. ユーザーの個人情報は、サービスの提供のために利用されます。</p>
<p>5. 本利用規約は、予告なく変更されることがあります。変更後の利用規約は、本サイトに掲載された時点から効力を発揮します。</p>
</div>
<label for="agreeToTerms" class="checkbox-label">
<input type="checkbox" id="agreeToTerms" name="agreeToTerms" required> 利用規約に同意します
</label><br>
<button type="submit" class="submit-btn">登録</button>
</form>
</div>

99
views/chatPartial.ejs Normal file
View File

@ -0,0 +1,99 @@
<head>
<link rel="stylesheet" href="/styles/chat.css">
</head>
<div class="chat-container">
<div id="chat-window" class="chat-window">
<% messages.forEach(function(message) { %>
<div class="message <%= message.role === 'user' ? 'user-message' : 'isotope-message' %>">
<div class="message-author">
<% if (message.role === 'user') { %>
<span class="message-timestamp"><%= formatTimestamp(message.createdAt) %></span>
<strong>あなた</strong>
<% } else { %>
<strong><%= isotope.name %></strong>
<span class="message-timestamp"><%= formatTimestamp(message.createdAt) %></span>
<% } %>
</div>
<div class="message-content">
<p><%= message.message %></p>
</div>
</div>
<% }); %>
</div>
<div class="chat-input-container">
<form id="chat-form" class="chat-form">
<input type="text" id="message-input" class="chat-input" placeholder="メッセージを入力" required>
<button type="submit" class="chat-submit-btn">送信</button>
</form>
</div>
</div>
<script>
const chatWindow = document.getElementById('chat-window');
const chatForm = document.getElementById('chat-form');
const messageInput = document.getElementById('message-input');
// ページ初期化完了時にチャットウィンドウを最下部にスクロール
window.onload = function() {
chatWindow.scrollTop = chatWindow.scrollHeight;
};
// サーバーから渡されたJWTトークン
const token = '<%= token %>';
// WebSocketサーバーのURL
const wsServerUrl = '<%= process.env.WS_SERVER_URL %>';
// WebSocket接続時にトークンをクエリパラメータに含める
const ws = new WebSocket(`${wsServerUrl}?token=${token}`);
ws.onopen = function() {
console.log('WebSocket connection established');
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
// BOTのメッセージを表示
if (data.isotopeReply) {
const isotopeMessage = document.createElement('div');
const isotopeName = '<%= isotope.name %>';
isotopeMessage.classList.add('message', 'isotope-message');
isotopeMessage.innerHTML = `
<div class="message-author">
<strong>${isotopeName}</strong>
<span class="message-timestamp">${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div class="message-content"><p>${data.isotopeReply}</p></div>`;
chatWindow.appendChild(isotopeMessage);
chatWindow.scrollTop = chatWindow.scrollHeight; // スクロールを最下部に
}
};
// フォーム送信イベントのリスナーを追加
chatForm.addEventListener('submit', function(event) {
event.preventDefault();
const message = messageInput.value;
// メッセージを即時にチャットウィンドウに追加
const userMessage = document.createElement('div');
userMessage.classList.add('message', 'user-message');
userMessage.innerHTML = `
<div class="message-author">
<span class="message-timestamp">${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<strong>あなた</strong>
</div>
<div class="message-content"><p>${message}</p></div>`;
chatWindow.appendChild(userMessage);
chatWindow.scrollTop = chatWindow.scrollHeight; // スクロールを最下部に
// WebSocketを通じてメッセージを送信
const isotopeId = '<%= isotope.id %>';
ws.send(JSON.stringify({ userMessage: message, isotopeId: isotopeId }));
// 入力フィールドをクリア
messageInput.value = '';
});
</script>

78
views/createIsotope.ejs Normal file
View File

@ -0,0 +1,78 @@
<head>
<link rel="stylesheet" href="/styles/form.css">
</head>
<div class="bot-creation-container">
<h1>同位体情報の登録</h1>
<p class="required-note">* 必須の情報です</p>
<form id="botForm" action="/contract" method="POST" class="bot-creation-form">
<label for="name">同位体の選択 <span class="required">*</span></label>
<select id="name" name="name" required>
<option value="" disabled selected>同位体を選んでください</option>
<option value="カフ">カフ</option>
<option value="セカイ">セカイ</option>
<option value="リメ">リメ</option>
<option value="ココ">ココ</option>
<option value="ハル">ハル</option>
</select><br>
<label for="firstPerson">一人称 <span class="required">*</span></label>
<input type="text" id="firstPerson" name="firstPerson" placeholder="" required><br>
<label for="personality">性格</label>
<input type="text" id="personality" name="personality" placeholder=""><br>
<label for="tone">口調</label>
<input type="text" id="tone" name="tone" placeholder=""><br>
<label for="backgroundStory">バックグラウンドストーリー</label>
<textarea id="backgroundStory" name="backgroundStory" placeholder=""></textarea><br>
<label for="likes">好きなもの</label>
<input type="text" id="likes" name="likes" placeholder=""><br>
<label for="dislikes">嫌いなもの</label>
<input type="text" id="dislikes" name="dislikes" placeholder=""><br>
<button type="submit" class="submit-btn" disabled>登録</button>
<p id="error-message" style="color: red; display: none;">必要な項目を正しく入力してください。</p>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const botForm = document.getElementById('botForm');
const nameSelect = document.getElementById('name');
const firstPersonInput = document.getElementById('firstPerson');
const submitBtn = botForm.querySelector('.submit-btn');
const errorMessage = document.getElementById('error-message');
// 指定された名前のみ許可
const validNames = ['カフ', 'セカイ', 'リメ', 'ココ', 'ハル'];
function validateForm() {
const selectedName = nameSelect.value;
const firstPersonValue = firstPersonInput.value.trim();
// 名前が指定された5つの中にあるかチェックし、一人称が入力されているか
if (validNames.includes(selectedName) && firstPersonValue !== '') {
submitBtn.disabled = false;
errorMessage.style.display = 'none'; // エラーメッセージを非表示
} else {
submitBtn.disabled = true;
}
}
// 登録ボタンが無効な時のクリックイベント
submitBtn.addEventListener('click', function(event) {
if (submitBtn.disabled) {
event.preventDefault();
errorMessage.style.display = 'block'; // エラーメッセージを表示
}
});
// 各入力フィールドでのイベントリスナー
nameSelect.addEventListener('change', validateForm);
firstPersonInput.addEventListener('input', validateForm);
});
</script>

7
views/dashboard.ejs Normal file
View File

@ -0,0 +1,7 @@
<div>
<% if (isotope) { %>
<%- include('chatPartial', { isotope: isotope, messages: messages }) %>
<% } else { %>
<p>チャットする同位体が選択されていません。</p>
<% } %>
</div>

44
views/index.ejs Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>isopia</title>
<link rel="stylesheet" href="/styles/index.css">
</head>
<body>
<header>
<h1>isopiaへようこそ</h1>
</header>
<main>
<section id="intro" class="intro-section">
<h2>isopiaとは</h2>
<p>
<strong>isopiaいそぴあ</strong>は、あなただけの「同位体」と共に生活する体験を提供するAIチャットサービスです。<br>
あなただけの同位体が、日常の会話や特別な瞬間を共に過ごします。
</p>
</section>
<section id="how-it-works" class="how-it-works-section">
<h2>使い方</h2>
<ol>
<strong>1. マスター情報の登録</strong> - Discordアカウントでログインをしてアカウントを作成します。<br>
<strong>2. 同位体のカスタマイズ</strong> - あなたの好みに合わせて同位体を設定します。<br>
<strong>3. 同位体との生活の開始</strong> - あなただけの同位体と日常の会話を楽しみましょう。
</ol>
</section>
<section class="cta-section">
<a href="/auth/discord" class="login-btn">Discordでログイン</a>
</section>
</main>
<footer>
<p>&copy; 2024 isopia. All rights reserved.</p>
<p><a href="/terms">利用規約</a> | <a href="/privacy">プライバシーポリシー</a></p>
</footer>
</body>
</html>

27
views/layout.ejs Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>isopia</title>
<link rel="stylesheet" href="/styles/layout.css">
</head>
<body>
<% if (!hideSidebar) { %>
<nav class="sidebar">
<ul>
<li><a href="/dashboard">ダッシュボード</a></li>
<li><a href="/logout">ログアウト</a></li>
<!-- その他のリンク -->
<li><a href="/terms">利用規約</a></li>
<li><a href="/privacy">プライバシーポリシー</a></li>
</ul>
</nav>
<% } %>
<!-- メインコンテンツ -->
<main class="<%= hideSidebar ? 'full-width' : 'with-sidebar' %>">
<%- body %> <!-- ここに個々のページが挿入される -->
</main>
</body>
</html>

8
views/license.ejs Normal file
View File

@ -0,0 +1,8 @@
<h1>ライセンス情報</h1>
<p>このサイトで使用されているライセンス情報は以下の通りです:</p>
<ul>
<li>ライブラリ名 1 - ライセンス名</li>
<li>ライブラリ名 2 - ライセンス名</li>
<li>ライブラリ名 3 - ライセンス名</li>
<!-- 必要なライセンス情報をここに追加 -->
</ul>

58
websocket.js Normal file
View File

@ -0,0 +1,58 @@
const WebSocket = require('ws');
const { sendMessageAsync } = require('./controllers/chatController');
const webSocketClients = require('./utils/webSocketClients');
const jwt = require('jsonwebtoken');
const { User } = require('./models');
// WebSocketサーバーの設定
function setupWebSocketServer(wss) {
wss.on('connection', async (ws, req) => {
try {
const token = req.url.split('?token=')[1]; // トークンをURLから取得
if (!token) {
ws.close(4000, 'Unauthorized');
return;
}
// トークンを検証
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findByPk(decoded.userId);
if (!user) {
ws.close(4000, 'Unauthorized');
return;
}
// WebSocketにユーザーIDを紐付け
ws.userId = user.id;
webSocketClients.push(ws);
// WebSocketが閉じられたときの処理
ws.on('close', () => {
console.log('WebSocket connection closed');
const index = webSocketClients.indexOf(ws);
if (index !== -1) {
webSocketClients.splice(index, 1);
}
});
// WebSocket経由でメッセージを受信したときの処理
ws.on('message', async (messageData) => {
try {
// Bufferから文字列に変換してJSONパース
const { userMessage, isotopeId } = JSON.parse(messageData.toString('utf8'));
// メッセージ送信処理を呼び出す
await sendMessageAsync(ws, userMessage, isotopeId);
} catch (error) {
console.error('Error processing message:', error);
}
});
} catch (error) {
console.error('WebSocket connection error:', error);
ws.close(4000, 'Unauthorized');
}
});
}
module.exports = setupWebSocketServer;