Compare commits

..

3 Commits

Author SHA1 Message Date
0179bf75f2 Update et upgrade by codex 2025-10-24 13:09:18 +02:00
130e21c867 Add multi-provider AI support (Claude and Mistral)
- Update runRound() to dynamically select correct AI client based on session.aiProvider
- Modify POST /api/collaborate to accept and validate aiProvider parameter
- Add aiProvider field to session creation response
- Add AI provider selector dropdown to frontend (Mistral Large 2411 vs Claude 3.5 Sonnet)
- Update collaboration store to pass aiProvider parameter through API

Users can now choose between Mistral and Claude AI for their collaborative sessions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 16:35:21 +02:00
60fdc9a66f Add Claude AI support as alternative to Mistral
- Create claudeClient.js service for Claude API integration
- Add CLAUDE_API_KEY to .env configuration
- Claude uses identical prompt structure and section extraction logic
- Supports claude-3-5-sonnet-20241022 model

Next: Add provider selection to orchestrator and routes
2025-10-19 16:32:48 +02:00
7 changed files with 339 additions and 43 deletions

View File

@ -10,7 +10,7 @@ const router = express.Router()
*/
router.post('/', async (req, res) => {
try {
const { prompt, documentFormat = 'md', agentCount = 7 } = req.body
const { prompt, documentFormat = 'md', agentCount = 7, aiProvider = 'mistral' } = req.body
if (!prompt || prompt.trim().length === 0) {
return res.status(400).json({ error: 'Prompt is required' })
@ -20,13 +20,18 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Document format must be "md" or "txt"' })
}
if (!['mistral', 'claude'].includes(aiProvider)) {
return res.status(400).json({ error: 'AI provider must be "mistral" or "claude"' })
}
// Validate agent count
const validAgentCount = Math.min(Math.max(agentCount, 3), 50)
const sessionId = collaborativeOrchestrator.createSession(
prompt,
documentFormat,
validAgentCount
validAgentCount,
aiProvider
)
const sessionInfo = collaborativeOrchestrator.getSessionInfo(sessionId)
@ -36,6 +41,7 @@ router.post('/', async (req, res) => {
prompt,
documentFormat,
agentCount: validAgentCount,
aiProvider,
status: 'created',
agents: sessionInfo.agents,
message: 'Collaborative session created. Start the session to begin collaboration.'

View File

@ -0,0 +1,144 @@
import dotenv from 'dotenv'
dotenv.config()
const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY
const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages'
/**
* Generic AI prompt for collaborative document editing
*/
function getAgentPrompt(agentName) {
return `You are an AI assistant named ${agentName} collaborating on a technical document design.
Your responsibilities:
1. Review the current document structure
2. Either:
a) Modify ONE existing section (identified by #, ##, ###, #### headers), OR
b) Create a NEW section if you think it's needed, OR
c) Delete a section if you think it's redundant or not useful
3. Provide your thinking process and reasoning
4. Return ONLY the section (modified or new) with its header, or command to delete, or confirm it's good as-is
CRITICAL RULES - FOLLOW THESE EXACTLY:
- Work on exactly ONE section only
- NEVER return the entire document
- NEVER return multiple sections
- Return ONLY the section you're working on, not the whole document
- You CAN create a new section if document is missing important content
- You CAN delete a section if it's redundant, duplicate, or not useful
- To delete a section, respond: "DELETE: ## Section Name" (with exact header)
- If section is good, respond: "Section is good, no changes needed"
- Think step-by-step about what could be improved or removed
- Share your reasoning process
RESPONSE FORMAT - FOLLOW THIS EXACTLY:
THINKING: [Your analysis and reasoning about the current document]
DECISION: [Exactly what you will do: modify section X, create new section Y, delete section Z, or keep as-is]
SECTION:
[ONLY ONE: Either a markdown section starting with # or ##, a DELETE command, or the text "Section is good, no changes needed"]
EXAMPLE OF CORRECT RESPONSE:
THINKING: The Overview section is too brief and doesn't explain the main purpose.
DECISION: I will modify the Overview section to be more comprehensive.
SECTION:
## Overview
This is a technical document for designing system architecture...
EXAMPLE OF INCORRECT RESPONSE (DO NOT DO THIS):
[The entire document repeated here] <- WRONG!`
}
/**
* Call Claude API
*/
async function callClaudeAPI(messages) {
const response = await fetch(CLAUDE_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': CLAUDE_API_KEY,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2048,
system: messages[0].content,
messages: messages.slice(1)
})
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Claude API error: ${error}`)
}
return response
}
/**
* Generate agent response using Claude
*/
export async function generateAgentResponseSync(agentName, prompt, currentDocument = '') {
const systemPrompt = getAgentPrompt(agentName)
const messages = [
{ role: 'user', content: systemPrompt },
{ role: 'user', content: `Project description: ${prompt}\n\nCurrent document:\n${currentDocument}` }
]
try {
const response = await callClaudeAPI(messages)
const data = await response.json()
if (!data.content?.[0]?.text) {
throw new Error('Invalid response from Claude API')
}
return data.content[0].text
} catch (error) {
console.error(`Error generating response from ${agentName}:`, error)
return `Error: ${error.message}`
}
}
/**
* Extract section from AI response
* Validates that we get a proper section, not the entire document
*/
export function extractSection(aiResponse) {
const sectionMatch = aiResponse.match(/SECTION:\s*([\s\S]*?)(?:$)/)
if (sectionMatch) {
const extracted = sectionMatch[1].trim()
// Validate: extracted should be either:
// 1. A markdown section starting with # (DELETE: or "Section is good...")
// 2. OR a single section with < 5000 chars (not entire document)
const isMarkdownSection = /^(#|DELETE:|Section is good)/.test(extracted)
const isShortEnough = extracted.length < 5000 // Single section should be < 5KB
if (isMarkdownSection || isShortEnough) {
return extracted
}
}
// Fallback: if no SECTION: tag found, treat entire response as section
// but only if it starts with markdown header or is a command
if (/^(#|DELETE:|Section is good)/.test(aiResponse)) {
return aiResponse.trim()
}
// If none of the above, return error indicator
return "ERROR: Response does not contain a valid section format"
}
/**
* Extract thinking from AI response
*/
export function extractThinking(aiResponse) {
const thinkingMatch = aiResponse.match(/THINKING:\s*([\s\S]*?)(?:DECISION:|SECTION:)/)
if (thinkingMatch) {
return thinkingMatch[1].trim()
}
return ''
}

View File

@ -1,5 +1,6 @@
import db from '../db/schema.js'
import { generateAgentResponseSync, extractSection, extractThinking } from './mistralClient.js'
import * as mistralClient from './mistralClient.js'
import * as claudeClient from './claudeClient.js'
import { getRandomNames } from './nameGenerator.js'
class CollaborativeOrchestrator {
@ -44,13 +45,13 @@ class CollaborativeOrchestrator {
/**
* Create a new collaborative session with N random-named agents
*/
createSession(initialPrompt, documentFormat = 'md', agentCount = 7) {
createSession(initialPrompt, documentFormat = 'md', agentCount = 7, aiProvider = 'mistral') {
const stmt = db.prepare(
'INSERT INTO collaborative_sessions (initial_prompt, document_format, status) VALUES (?, ?, ?)'
)
const result = stmt.run(initialPrompt, documentFormat, 'created')
const sessionId = result.lastInsertRowid
console.log(`[Session ${sessionId}] Created with ${agentCount} agents, format: ${documentFormat}`)
console.log(`[Session ${sessionId}] Created with ${agentCount} agents, format: ${documentFormat}, provider: ${aiProvider}`)
// Generate random names for agents
const agentNames = getRandomNames(Math.min(agentCount, 50))
@ -59,6 +60,7 @@ class CollaborativeOrchestrator {
id: sessionId,
initialPrompt,
documentFormat,
aiProvider, // Claude or Mistral
agents: agentNames, // Array of agent names
agentCount,
currentAgentIndex: 0,
@ -179,14 +181,15 @@ class CollaborativeOrchestrator {
try {
console.log(`[Session ${sessionId}] ${agentName} analyzing and generating response...`)
const response = await generateAgentResponseSync(
const client = session.aiProvider === 'claude' ? claudeClient : mistralClient
const response = await client.generateAgentResponseSync(
agentName,
session.initialPrompt,
session.currentDocument // <-- This is always the latest version
)
const thinking = extractThinking(response)
const section = extractSection(response)
const thinking = client.extractThinking(response)
const section = client.extractSection(response)
console.log(`[Session ${sessionId}] ${agentName} response received (${response.length} chars)`)
console.log(`[Session ${sessionId}] --- THINKING (${agentName}) ---`)

View File

@ -9,24 +9,23 @@ const collaborationStore = useCollaborationStore()
const prompt = ref('')
const contextFile = ref(null)
const agentCount = ref(7)
const aiProvider = ref('mistral')
const documentFormat = ref('md')
const isCreating = ref(false)
const previousSessions = ref([])
const loadingPreviousSessions = ref(false)
const showAllSessions = ref(false)
const showArchives = ref(false)
const sessionStatusFilter = ref('all') // 'all', 'completed', 'ongoing', 'created'
const searchQuery = ref('')
const isFetchingSessions = ref(false)
onMounted(async () => {
loadingPreviousSessions.value = true
if (collaborationStore.sessionsLoaded.value) return
isFetchingSessions.value = true
try {
const response = await fetch('/api/collaborate')
const data = await response.json()
previousSessions.value = data.sessions || []
await collaborationStore.fetchSessions()
} catch (error) {
console.error('Error loading previous sessions:', error)
} finally {
loadingPreviousSessions.value = false
isFetchingSessions.value = false
}
})
@ -34,6 +33,8 @@ const handleOpenSession = (sessionId) => {
emit('session-created', { sessionId })
}
const sessions = computed(() => collaborationStore.sessions.value)
const agentOptions = computed(() => {
return Array.from({ length: 48 }, (_, i) => ({
value: i + 3,
@ -42,7 +43,7 @@ const agentOptions = computed(() => {
})
const filteredSessions = computed(() => {
let filtered = previousSessions.value
let filtered = sessions.value
if (sessionStatusFilter.value !== 'all') {
filtered = filtered.filter(s => s.status === sessionStatusFilter.value)
@ -64,11 +65,11 @@ const displayedSessions = computed(() => {
})
const completedCount = computed(() => {
return previousSessions.value.filter(s => s.status === 'completed').length
return sessions.value.filter(s => s.status === 'completed').length
})
const ongoingCount = computed(() => {
return previousSessions.value.filter(s => s.status === 'ongoing').length
return sessions.value.filter(s => s.status === 'ongoing').length
})
const handleFileSelect = (event) => {
@ -107,8 +108,9 @@ const handleCreateSession = async () => {
// Always use 'md' format for output
const session = await collaborationStore.createSession(
finalPrompt,
'md',
agentCount.value
documentFormat.value,
agentCount.value,
aiProvider.value
)
emit('session-created', session)
@ -117,6 +119,8 @@ const handleCreateSession = async () => {
prompt.value = ''
contextFile.value = null
agentCount.value = 7
aiProvider.value = 'mistral'
documentFormat.value = 'md'
} catch (error) {
alert(`Error creating session: ${collaborationStore.error}`)
} finally {
@ -147,7 +151,11 @@ const removeFile = () => {
</header>
<!-- Quick Access Section -->
<div v-if="previousSessions.length > 0" class="quick-access-section">
<div v-if="isFetchingSessions" class="loading-sessions">
Loading previous sessions...
</div>
<div v-else-if="sessions.length > 0" class="quick-access-section">
<div class="stats-bar">
<div class="stat">
<span class="stat-label">Completed</span>
@ -159,7 +167,7 @@ const removeFile = () => {
</div>
<div class="stat">
<span class="stat-label">Total</span>
<span class="stat-value">{{ previousSessions.length }}</span>
<span class="stat-value">{{ sessions.length }}</span>
</div>
</div>
@ -180,7 +188,7 @@ const removeFile = () => {
class="filter-btn"
:class="{ active: sessionStatusFilter === 'all' }"
>
All Sessions ({{ previousSessions.length }})
All Sessions ({{ sessions.length }})
</button>
<button
@click="sessionStatusFilter = 'completed'"
@ -234,7 +242,7 @@ const removeFile = () => {
</div>
<!-- Divider -->
<div v-if="previousSessions.length > 0" class="divider">
<div v-if="sessions.length > 0" class="divider">
<span>Or start a new session</span>
</div>
@ -290,7 +298,7 @@ const removeFile = () => {
<p class="hint">Optional: Provide existing documentation or requirements to guide the design.</p>
</div>
<!-- Agent Count Selection -->
<!-- Agent Count and AI Provider Selection -->
<div class="form-grid">
<div class="form-group">
<label for="agents" class="label">Number of AI Specialists</label>
@ -301,6 +309,22 @@ const removeFile = () => {
</select>
<p class="hint">More agents = more diverse perspectives.</p>
</div>
<div class="form-group">
<label for="provider" class="label">AI Provider</label>
<select v-model="aiProvider" id="provider" class="select">
<option value="mistral">Mistral (Large 2411)</option>
<option value="claude">Claude (3.5 Sonnet)</option>
</select>
<p class="hint">Choose the AI model for specialists.</p>
</div>
<div class="form-group">
<label for="format" class="label">Document Format</label>
<select v-model="documentFormat" id="format" class="select">
<option value="md">Markdown (.md)</option>
<option value="txt">Plain Text (.txt)</option>
</select>
<p class="hint">Markdown recommended for structured documents.</p>
</div>
</div>
<!-- Info Box -->
@ -613,6 +637,17 @@ const removeFile = () => {
cursor: not-allowed;
}
.loading-sessions {
margin-bottom: 2rem;
padding: 1rem;
text-align: center;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
backdrop-filter: blur(8px);
}
.file-input-wrapper {
position: relative;
margin-bottom: 1rem;

View File

@ -80,11 +80,19 @@ const handleWebSocketMessage = (message) => {
if (message.type === 'initial_document_created') {
sessionStarted.value = true
collaborationStore.currentDocument = message.content
collaborationStore.currentRound = 0
if (collaborationStore.currentSession) {
collaborationStore.currentSession.status = 'ongoing'
collaborationStore.currentSession.currentDocument = message.content
}
currentWorkingAgent.value = null
currentAgentThinking.value = ''
scheduleNextRound(2000)
} else if (message.type === 'document_modified') {
collaborationStore.currentDocument = message.content
if (collaborationStore.currentSession) {
collaborationStore.currentSession.currentDocument = message.content
}
} else if (message.type === 'agent_working') {
currentWorkingAgent.value = message.agentName
currentAgentThinking.value = ''
@ -92,11 +100,17 @@ const handleWebSocketMessage = (message) => {
currentAgentThinking.value = message.thinking || ''
} else if (message.type === 'round_complete') {
isRunningRound.value = false
collaborationStore.conversationHistory.push({
const roundEntry = {
roundNumber: message.roundNumber,
agentsMadeChanges: message.agentsMadeChanges,
timestamp: Date.now()
})
}
collaborationStore.conversationHistory.push(roundEntry)
if (collaborationStore.currentSession) {
const history = collaborationStore.currentSession.conversationHistory || []
collaborationStore.currentSession.conversationHistory = [...history, roundEntry]
}
collaborationStore.currentRound = message.roundNumber
currentWorkingAgent.value = null
currentAgentThinking.value = ''
@ -116,6 +130,9 @@ const handleWebSocketMessage = (message) => {
} else if (message.type === 'session_completed') {
currentWorkingAgent.value = null
isAutoRunning.value = false
if (collaborationStore.currentSession) {
collaborationStore.currentSession.status = 'completed'
}
}
}
@ -237,6 +254,10 @@ function formatAgentName(agent) {
<span class="hand"></span>
</div>
<div class="agent-name-working">{{ formatAgentName(currentWorkingAgent) }}</div>
<div class="agent-thinking">
<span v-if="currentAgentThinking">{{ currentAgentThinking }}</span>
<span v-else>Analyzing the document...</span>
</div>
</div>
<!-- Convergence Message -->
@ -461,6 +482,16 @@ function formatAgentName(agent) {
font-weight: 600;
}
.agent-thinking {
flex: 1;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.75);
background: rgba(102, 126, 234, 0.08);
border-radius: 12px;
padding: 0.75rem 1rem;
line-height: 1.4;
}
.convergence-message {
display: flex;
align-items: center;

View File

@ -1,7 +1,8 @@
<script setup>
import { computed } from 'vue'
import { computed, onMounted, watch, ref, nextTick } from 'vue'
import { marked } from 'marked'
import hljs from 'highlight.js'
import mermaid from 'mermaid'
const props = defineProps({
document: {
@ -23,22 +24,28 @@ marked.setOptions({
const renderer = new marked.Renderer()
// Override code block rendering to add syntax highlighting
renderer.code = ({ text, lang }) => {
const language = lang || 'plain'
let highlighted = text
// Configure mermaid once
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'loose'
})
if (hljs.getLanguage(language)) {
highlighted = hljs.highlight(text, { language }).value
} else {
highlighted = hljs.highlight(text, { language: 'plain' }).value
// Override code block rendering to handle Mermaid and syntax highlighting
renderer.code = ({ text, lang }) => {
if ((lang || '').toLowerCase() === 'mermaid') {
return `<div class="mermaid">${text}</div>`
}
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext'
const highlighted = hljs.highlight(text, { language }).value
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`
}
marked.setOptions({ renderer })
const containerRef = ref(null)
const renderedContent = computed(() => {
if (!props.document) {
return '<p class="empty-state">No document content yet. Start the session to begin collaboration.</p>'
@ -57,18 +64,40 @@ const renderedContent = computed(() => {
return `<pre><code>${escaped}</code></pre>`
}
})
async function renderMermaid() {
await nextTick()
if (!containerRef.value) return
const nodes = containerRef.value.querySelectorAll('.mermaid')
if (!nodes.length) return
try {
await mermaid.run({ nodes })
} catch (error) {
console.error('Mermaid render error:', error)
}
}
onMounted(() => {
renderMermaid()
})
watch(() => [props.document, props.format], () => {
renderMermaid()
})
</script>
<template>
<div class="document-viewer">
<div class="document-container" :class="`format-${format}`">
<div v-html="renderedContent" class="document-content"></div>
<div ref="containerRef" v-html="renderedContent" class="document-content"></div>
</div>
</div>
</template>
<style scoped>
:import 'highlight.js/styles/atom-one-dark.css';
@import 'highlight.js/styles/atom-one-dark.css';
.document-viewer {
width: 100%;

View File

@ -9,13 +9,40 @@ export const useCollaborationStore = defineStore('collaboration', () => {
const currentDocument = ref('')
const currentRound = ref(0)
const conversationHistory = ref([])
const sessionsLoaded = ref(false)
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
/**
* Fetch recent collaborative sessions
*/
async function fetchSessions() {
if (loading.value) return
loading.value = true
error.value = null
try {
const response = await fetch(`${API_URL}/api/collaborate`)
if (!response.ok) {
throw new Error('Failed to fetch sessions')
}
const data = await response.json()
sessions.value = data.sessions || []
sessionsLoaded.value = true
return sessions.value
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
/**
* Create a new collaborative session
*/
async function createSession(prompt, documentFormat = 'md', agentCount = 7) {
async function createSession(prompt, documentFormat = 'md', agentCount = 7, aiProvider = 'mistral') {
loading.value = true
error.value = null
@ -28,7 +55,8 @@ export const useCollaborationStore = defineStore('collaboration', () => {
body: JSON.stringify({
prompt,
documentFormat,
agentCount
agentCount,
aiProvider
})
})
@ -38,7 +66,8 @@ export const useCollaborationStore = defineStore('collaboration', () => {
const data = await response.json()
currentSession.value = data
sessions.value.unshift(data)
sessions.value = [data, ...sessions.value.filter(s => s.sessionId !== data.sessionId)]
sessionsLoaded.value = true
return data
} catch (err) {
@ -152,7 +181,24 @@ export const useCollaborationStore = defineStore('collaboration', () => {
throw new Error('Failed to complete session')
}
return await response.json()
const data = await response.json()
if (currentSession.value && currentSession.value.sessionId === sessionId) {
currentSession.value = {
...currentSession.value,
status: 'completed',
completedAt: new Date().toISOString()
}
}
sessions.value = sessions.value.map(session => {
if (session.sessionId === sessionId) {
return { ...session, status: 'completed', completedAt: new Date().toISOString() }
}
return session
})
return data
} catch (err) {
error.value = err.message
throw err
@ -190,11 +236,13 @@ export const useCollaborationStore = defineStore('collaboration', () => {
return {
currentSession,
sessions,
sessionsLoaded,
loading,
error,
currentDocument,
currentRound,
conversationHistory,
fetchSessions,
createSession,
startSession,
runRound,