Add full-stack implementation with Vue.js frontend and Node.js backend

- Add MIT License
- Create frontend with Vue 3 + Vite + Pinia
  - PromptInput component for project description
  - DebateThread component for displaying AI discussions
  - Debate store for state management
- Create backend with Express + WebSocket + SQLite
  - REST API for debate management
  - Database schema for debates and responses
  - Orchestrator service for AI agent coordination
- Update .gitignore for environment files and dependencies
This commit is contained in:
Augustin ROUX 2025-10-17 11:37:59 +02:00
parent f5c369a434
commit 04e6c062a5
26 changed files with 5579 additions and 0 deletions

33
.gitignore vendored
View File

@ -3,3 +3,36 @@
# Design journal with sensitive information # Design journal with sensitive information
design-journal.md design-journal.md
# Dependencies
node_modules/
frontend/node_modules/
backend/node_modules/
# Environment variables
.env
frontend/.env
backend/.env
# Database files
backend/data/
*.db
*.sqlite
# Logs
*.log
npm-debug.log*
# Build outputs
frontend/dist/
frontend/.vite/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Augustin ROUX (Muyue)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

4
backend/.env.example Normal file
View File

@ -0,0 +1,4 @@
PORT=3000
FRONTEND_URL=http://localhost:5173
MISTRAL_API_KEY=your_mistral_api_key_here
DATABASE_PATH=./data/agora.db

6
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
.env
data/
*.log
*.db
.DS_Store

1350
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "agora-ai-backend",
"version": "1.0.0",
"description": "Backend orchestration for Project Agora multi-agent AI system",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"ai",
"multi-agent",
"orchestration"
],
"author": "Augustin ROUX (Muyue)",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^12.4.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"helmet": "^8.1.0",
"ws": "^8.18.3"
}
}

44
backend/src/db/schema.js Normal file
View File

@ -0,0 +1,44 @@
import Database from 'better-sqlite3';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { mkdirSync, existsSync } from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = process.env.DATABASE_PATH || join(__dirname, '../../data/agora.db');
// Ensure data directory exists
const dataDir = dirname(dbPath);
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Create debates table
db.exec(`
CREATE TABLE IF NOT EXISTS debates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status TEXT CHECK(status IN ('ongoing', 'completed', 'failed')) DEFAULT 'ongoing'
)
`);
// Create responses table
db.exec(`
CREATE TABLE IF NOT EXISTS responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
debate_id INTEGER NOT NULL,
agent_role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (debate_id) REFERENCES debates(id) ON DELETE CASCADE
)
`);
console.log('Database initialized successfully');
export default db;

59
backend/src/index.js Normal file
View File

@ -0,0 +1,59 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import rateLimit from 'express-rate-limit';
import db from './db/schema.js';
import debateRoutes from './routes/debate.js';
dotenv.config();
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
const PORT = process.env.PORT || 3000;
// Middleware
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true
}));
app.use(express.json());
// Rate limiting
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10 // 10 requests per minute
});
app.use('/api', limiter);
// WebSocket connection handling
wss.on('connection', (ws) => {
console.log('New WebSocket connection established');
ws.on('message', (message) => {
console.log('Received:', message.toString());
// Handle incoming messages
});
ws.on('close', () => {
console.log('WebSocket connection closed');
});
});
// Routes
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Agora AI Backend is running' });
});
app.use('/api/debate', debateRoutes);
// Start server
server.listen(PORT, () => {
console.log(`Agora AI Backend running on port ${PORT}`);
console.log(`WebSocket server ready`);
});

View File

@ -0,0 +1,88 @@
import express from 'express';
import orchestrator from '../services/orchestrator.js';
const router = express.Router();
/**
* POST /api/debate
* Create a new debate
*/
router.post('/', async (req, res) => {
try {
const { prompt } = req.body;
if (!prompt || prompt.trim().length === 0) {
return res.status(400).json({ error: 'Prompt is required' });
}
const debateId = orchestrator.createDebate(prompt);
const agents = orchestrator.selectAgents(prompt);
res.json({
debateId,
prompt,
agents,
status: 'ongoing'
});
} catch (error) {
console.error('Error creating debate:', error);
res.status(500).json({ error: 'Failed to create debate' });
}
});
/**
* GET /api/debate/:id
* Get debate details and responses
*/
router.get('/:id', (req, res) => {
try {
const debateId = parseInt(req.params.id);
const debate = orchestrator.getDebate(debateId);
if (!debate) {
return res.status(404).json({ error: 'Debate not found' });
}
const responses = orchestrator.getDebateResponses(debateId);
res.json({
...debate,
responses: responses.map(r => ({
...r,
content: JSON.parse(r.content)
}))
});
} catch (error) {
console.error('Error fetching debate:', error);
res.status(500).json({ error: 'Failed to fetch debate' });
}
});
/**
* POST /api/debate/:id/response
* Add a response to a debate
*/
router.post('/:id/response', (req, res) => {
try {
const debateId = parseInt(req.params.id);
const { agentRole, content } = req.body;
if (!agentRole || !content) {
return res.status(400).json({ error: 'Agent role and content are required' });
}
const responseId = orchestrator.addResponse(debateId, agentRole, content);
res.json({
responseId,
debateId,
agentRole,
content
});
} catch (error) {
console.error('Error adding response:', error);
res.status(500).json({ error: 'Failed to add response' });
}
});
export default router;

View File

@ -0,0 +1,107 @@
import db from '../db/schema.js';
class Orchestrator {
constructor() {
this.activeDebates = new Map();
}
/**
* Create a new debate
*/
createDebate(prompt) {
const stmt = db.prepare('INSERT INTO debates (prompt, status) VALUES (?, ?)');
const result = stmt.run(prompt, 'ongoing');
const debateId = result.lastInsertRowid;
this.activeDebates.set(debateId, {
id: debateId,
prompt,
responses: [],
startTime: Date.now()
});
return debateId;
}
/**
* Get debate by ID
*/
getDebate(debateId) {
const stmt = db.prepare('SELECT * FROM debates WHERE id = ?');
return stmt.get(debateId);
}
/**
* Get all responses for a debate
*/
getDebateResponses(debateId) {
const stmt = db.prepare('SELECT * FROM responses WHERE debate_id = ? ORDER BY timestamp ASC');
return stmt.all(debateId);
}
/**
* Add a response to a debate
*/
addResponse(debateId, agentRole, content) {
const stmt = db.prepare(
'INSERT INTO responses (debate_id, agent_role, content) VALUES (?, ?, ?)'
);
const result = stmt.run(debateId, agentRole, JSON.stringify(content));
if (this.activeDebates.has(debateId)) {
this.activeDebates.get(debateId).responses.push({
agentRole,
content,
timestamp: new Date()
});
}
return result.lastInsertRowid;
}
/**
* Complete a debate
*/
completeDebate(debateId) {
const stmt = db.prepare('UPDATE debates SET status = ? WHERE id = ?');
stmt.run('completed', debateId);
this.activeDebates.delete(debateId);
}
/**
* Fail a debate
*/
failDebate(debateId) {
const stmt = db.prepare('UPDATE debates SET status = ? WHERE id = ?');
stmt.run('failed', debateId);
this.activeDebates.delete(debateId);
}
/**
* Select relevant agents based on prompt analysis
*/
selectAgents(prompt) {
const agents = ['architect'];
const lowerPrompt = prompt.toLowerCase();
// Analyze prompt for relevant expertise
if (lowerPrompt.includes('api') || lowerPrompt.includes('backend') || lowerPrompt.includes('database')) {
agents.push('backend_engineer');
}
if (lowerPrompt.includes('ui') || lowerPrompt.includes('frontend') || lowerPrompt.includes('interface')) {
agents.push('frontend_engineer');
}
if (lowerPrompt.includes('design') || lowerPrompt.includes('ux') || lowerPrompt.includes('user')) {
agents.push('designer');
}
// Always include at least architect and one engineer
if (agents.length === 1) {
agents.push('backend_engineer', 'frontend_engineer');
}
return agents;
}
}
export default new Orchestrator();

1
frontend/.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3104
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^13.9.0",
"mermaid": "^11.12.0",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"vue": "^3.5.22"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.7"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

90
frontend/src/App.vue Normal file
View File

@ -0,0 +1,90 @@
<script setup>
import { ref } from 'vue'
import { useDebateStore } from './stores/debate'
import PromptInput from './components/PromptInput.vue'
import DebateThread from './components/DebateThread.vue'
const debateStore = useDebateStore()
const showDebate = ref(false)
function handleDebateCreated(debate) {
showDebate.value = true
}
function startNewDebate() {
debateStore.clearCurrentDebate()
showDebate.value = false
}
</script>
<template>
<div class="app">
<div v-if="!showDebate">
<PromptInput @debate-created="handleDebateCreated" />
</div>
<div v-else>
<div class="debate-view">
<button @click="startNewDebate" class="new-debate-btn">
+ New Debate
</button>
<DebateThread
v-if="debateStore.currentDebate"
:debate="debateStore.currentDebate"
/>
</div>
</div>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f5f7fa;
color: #2c3e50;
}
#app {
min-height: 100vh;
}
</style>
<style scoped>
.app {
min-height: 100vh;
padding: 2rem;
}
.debate-view {
position: relative;
}
.new-debate-btn {
position: fixed;
top: 2rem;
right: 2rem;
padding: 0.75rem 1.5rem;
background-color: white;
border: 2px solid #667eea;
color: #667eea;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.new-debate-btn:hover {
background-color: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
</style>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,209 @@
<template>
<div class="debate-thread">
<div class="debate-header">
<h2>Debate #{{ debate.debateId }}</h2>
<span class="status" :class="debate.status">{{ debate.status }}</span>
</div>
<div class="prompt-display">
<strong>Project Prompt:</strong>
<p>{{ debate.prompt }}</p>
</div>
<div class="agents">
<strong>Participating Agents:</strong>
<div class="agent-list">
<span
v-for="agent in debate.agents"
:key="agent"
class="agent-badge"
:class="getAgentClass(agent)"
>
{{ formatAgentName(agent) }}
</span>
</div>
</div>
<div class="responses" v-if="debate.responses && debate.responses.length > 0">
<h3>Debate Responses</h3>
<div
v-for="(response, index) in debate.responses"
:key="index"
class="response-card"
:class="getAgentClass(response.agent_role)"
>
<div class="response-header">
<span class="agent-name">{{ formatAgentName(response.agent_role) }}</span>
<span class="timestamp">{{ formatTimestamp(response.timestamp) }}</span>
</div>
<div class="response-content">
{{ typeof response.content === 'string' ? response.content : JSON.stringify(response.content, null, 2) }}
</div>
</div>
</div>
<div v-else class="no-responses">
<p>Waiting for AI agents to respond...</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
debate: {
type: Object,
required: true
}
})
function formatAgentName(agent) {
return agent
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
function getAgentClass(agent) {
const classes = {
'architect': 'agent-architect',
'backend_engineer': 'agent-backend',
'frontend_engineer': 'agent-frontend',
'designer': 'agent-designer'
}
return classes[agent] || 'agent-default'
}
function formatTimestamp(timestamp) {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleTimeString()
}
</script>
<style scoped>
.debate-thread {
max-width: 900px;
margin: 2rem auto;
padding: 2rem;
}
.debate-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.status {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.status.ongoing {
background-color: #3498db;
color: white;
}
.status.completed {
background-color: #2ecc71;
color: white;
}
.status.failed {
background-color: #e74c3c;
color: white;
}
.prompt-display {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.prompt-display p {
margin: 0.5rem 0 0 0;
line-height: 1.6;
}
.agents {
margin-bottom: 2rem;
}
.agent-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.agent-badge {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
color: white;
}
.agent-architect {
background-color: #9b59b6;
}
.agent-backend {
background-color: #3498db;
}
.agent-frontend {
background-color: #1abc9c;
}
.agent-designer {
background-color: #e67e22;
}
.agent-default {
background-color: #95a5a6;
}
.responses h3 {
margin-bottom: 1rem;
}
.response-card {
background-color: white;
border-left: 4px solid;
padding: 1.5rem;
margin-bottom: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.response-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.agent-name {
font-weight: 600;
font-size: 1.1rem;
}
.timestamp {
color: #7f8c8d;
font-size: 0.9rem;
}
.response-content {
line-height: 1.6;
white-space: pre-wrap;
}
.no-responses {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
</style>

View File

@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,133 @@
<template>
<div class="prompt-input">
<h1>Project Agora</h1>
<p class="subtitle">AI-Powered Software Architecture Design</p>
<div class="input-container">
<textarea
v-model="prompt"
placeholder="Describe your project idea... (e.g., 'Build a real-time chat application with user authentication')"
rows="6"
@keydown.ctrl.enter="handleSubmit"
:disabled="loading"
/>
<button
@click="handleSubmit"
:disabled="!prompt.trim() || loading"
class="submit-btn"
>
{{ loading ? 'Creating Debate...' : 'Start AI Debate' }}
</button>
<p v-if="error" class="error">{{ error }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useDebateStore } from '../stores/debate'
const emit = defineEmits(['debate-created'])
const debateStore = useDebateStore()
const prompt = ref('')
const loading = ref(false)
const error = ref(null)
async function handleSubmit() {
if (!prompt.value.trim()) return
loading.value = true
error.value = null
try {
const debate = await debateStore.createDebate(prompt.value)
emit('debate-created', debate)
prompt.value = ''
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
</script>
<style scoped>
.prompt-input {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: #666;
margin-bottom: 2rem;
font-size: 1.1rem;
}
.input-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
textarea {
width: 100%;
padding: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-family: inherit;
font-size: 1rem;
resize: vertical;
transition: border-color 0.3s;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
textarea:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.submit-btn {
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, opacity 0.2s;
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.error {
color: #e74c3c;
font-size: 0.9rem;
margin: 0;
}
</style>

10
frontend/src/main.js Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')

View File

@ -0,0 +1,97 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useDebateStore = defineStore('debate', () => {
const currentDebate = ref(null)
const debates = ref([])
const loading = ref(false)
const error = ref(null)
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
/**
* Create a new debate
*/
async function createDebate(prompt) {
loading.value = true
error.value = null
try {
const response = await fetch(`${API_URL}/api/debate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ prompt })
})
if (!response.ok) {
throw new Error('Failed to create debate')
}
const data = await response.json()
currentDebate.value = data
debates.value.unshift(data)
return data
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
/**
* Get debate by ID
*/
async function getDebate(debateId) {
loading.value = true
error.value = null
try {
const response = await fetch(`${API_URL}/api/debate/${debateId}`)
if (!response.ok) {
throw new Error('Failed to fetch debate')
}
const data = await response.json()
currentDebate.value = data
return data
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
/**
* Add a response to current debate
*/
function addResponse(response) {
if (currentDebate.value && currentDebate.value.responses) {
currentDebate.value.responses.push(response)
}
}
/**
* Clear current debate
*/
function clearCurrentDebate() {
currentDebate.value = null
}
return {
currentDebate,
debates,
loading,
error,
createDebate,
getDebate,
addResponse,
clearCurrentDebate
}
})

79
frontend/src/style.css Normal file
View File

@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

7
frontend/vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})