Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dac3992
feat(api): LLM Gateway V2 integration
damienlaine Dec 11, 2025
52722aa
feat(api): export versioning and document generation
damienlaine Dec 11, 2025
55fa378
feat(websocket): real-time LLM job updates with authorization
damienlaine Dec 11, 2025
6f0e684
feat(frontend): publish page with LLM job management
damienlaine Dec 11, 2025
c2245dc
feat(api): add publication endpoints for document generation
damienlaine Dec 11, 2025
cf20b61
feat(api): add generation history tracking
damienlaine Dec 11, 2025
4151272
feat(ui): add publication and template management components
damienlaine Dec 12, 2025
4b5c6e1
feat(ui): add AI service menu and generation timeline
damienlaine Dec 12, 2025
62b2230
fix(i18n): add missing translation keys for publication feature
damienlaine Dec 13, 2025
d399722
chore: add tests and update dependencies
damienlaine Dec 13, 2025
0ebc389
fix(ui): hide regenerate button when document is up to date
damienlaine Dec 15, 2025
5ec3759
Fix timeline not refreshing after new generation completes
damienlaine Dec 15, 2025
2a7ae6e
Replace emojis with CSS icons for visual consistency
damienlaine Dec 15, 2025
c388527
feat(api): Remove local LLM Gateway result caching
damienlaine Dec 16, 2025
dec6d26
feat(frontend): Fetch LLM content from gateway instead of cache
damienlaine Dec 16, 2025
aba1206
test(api): Add tests for export content endpoint and auto-regeneration
damienlaine Dec 16, 2025
810b12a
feat: add WhisperX JSON export format
damienlaine Dec 17, 2025
e99acf7
refactor: deduplicate socket organization access checks
damienlaine Dec 21, 2025
5c71cfe
refactor: clean up migration with schema helper
damienlaine Dec 21, 2025
65af07f
refactor: remove unused generateDocxFromMarkdown function
damienlaine Dec 21, 2025
a7116e0
fix: add organizationId to public session tokens
damienlaine Dec 21, 2025
94a16ec
chore: remove proxy pattern comments
damienlaine Dec 21, 2025
c908ec9
refactor: use custom error classes in publication controller
damienlaine Dec 22, 2025
0376b6d
refactor: extend custom error classes to export and generations contr…
damienlaine Dec 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions studio-api/.envdefault
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ DB_MIGRATION_TARGET=1.6.1
# STT Config
GATEWAY_SERVICES=https://<domain>

# LLM Config
# LLM Gateway V2 Config
# HTTP API endpoint (e.g., http://localhost:8010)
LLM_GATEWAY_SERVICES=
LLM_GATEWAY_SERVICES_WS=
# WebSocket URL is automatically derived from LLM_GATEWAY_SERVICES
# (http://localhost:8010 -> ws://localhost:8010)
# V2 WebSocket endpoints:
# /ws/jobs/{job_id} - Single job monitoring
# /ws/jobs - Global jobs monitoring

# NLP Service
NLP_SERVICES=<nlp_service_host>
Expand Down
112 changes: 109 additions & 3 deletions studio-api/components/IoHandler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const { watchConversation, refreshInterval } = require(
const auth_middlewares = require(
`${process.cwd()}/components/WebServer/config/passport/middleware`,
)
const { sessionSocketAccess } = require(
const { sessionSocketAccess, checkSocketOrganizationAccess } = require(
`${process.cwd()}/components/WebServer/middlewares/access/organization.js`,
)

Expand Down Expand Up @@ -148,15 +148,25 @@ class IoHandler extends Component {
this.removeSocketFromRoom(roomId, socket)
})

socket.on("watch_organization_session", (orgaId) => {
socket.on("watch_organization_session", async (orgaId) => {
const { authorized } = await checkSocketOrganizationAccess(socket, orgaId)
if (!authorized) {
debug(`[Session] User denied access to org ${orgaId}`)
return
}
this.addSocketInOrga(orgaId, socket, "session")
})

socket.on("unwatch_organization_session", (orgaId) => {
this.removeSocketFromOrga(orgaId, socket)
})

socket.on("watch_organization_media", (orgaId) => {
socket.on("watch_organization_media", async (orgaId) => {
const { authorized } = await checkSocketOrganizationAccess(socket, orgaId)
if (!authorized) {
debug(`[Media] User denied access to org ${orgaId}`)
return
}
this.addSocketInMedia(orgaId, socket)
})

Expand All @@ -171,6 +181,70 @@ class IoHandler extends Component {
})
this.searchAndRemoveSocketFromRooms(socket)
})

// LLM job subscription handlers
socket.on("llm:join", async (data) => {
const { organizationId, conversationId } = data
if (!organizationId) return

// First, try normal authentication
const { authorized, userId } = await checkSocketOrganizationAccess(socket, organizationId)

if (!authorized) {
// Check for public session token
const publicToken = socket.handshake?.auth?.publicToken || socket.handshake?.query?.publicToken
if (publicToken) {
// Validate token and check if organizationId matches
const PublicToken = require(
`${process.cwd()}/components/WebServer/config/passport/token/public_generator`,
)
try {
// Decode without verification first to get the session ID
const jwt = require("jsonwebtoken")
const decoded = jwt.decode(publicToken)
if (decoded?.data?.fromSession && decoded?.data?.organizationId === organizationId) {
const validated = PublicToken.validateToken(publicToken, decoded.data.fromSession)
if (validated && validated.data?.organizationId === organizationId) {
debug(`[LLM] Public session user joined room for org ${organizationId}`)
const orgRoom = `llm/${organizationId}`
socket.join(orgRoom)
return
}
}
} catch (err) {
debug(`[LLM] Invalid public token: ${err.message}`)
}
}

debug(`[LLM] Unauthorized llm:join attempt for org ${organizationId}`)
return
}

// Join organization-level LLM room
const orgRoom = `llm/${organizationId}`
socket.join(orgRoom)
debug(`Socket ${socket.id} joined LLM room ${orgRoom}`)

// Join conversation-specific room if provided
if (conversationId) {
const convRoom = `llm/${organizationId}/${conversationId}`
socket.join(convRoom)
debug(`Socket ${socket.id} joined LLM room ${convRoom}`)
}
})

socket.on("llm:leave", (data) => {
const { organizationId, conversationId } = data
if (!organizationId) return

const orgRoom = `llm/${organizationId}`
socket.leave(orgRoom)

if (conversationId) {
const convRoom = `llm/${organizationId}/${conversationId}`
socket.leave(convRoom)
}
})
})

return this.init()
Expand Down Expand Up @@ -373,6 +447,38 @@ class IoHandler extends Component {
LogManager.logSystemEvent("Broker connection lost")
}
}

/**
* Broadcast LLM job update to subscribed clients
* @param {object} update - { organizationId, conversationId, jobId, status, progress, result, error }
*/
notifyLlmJobUpdate(update) {
const { organizationId, conversationId, jobId, status } = update
if (!organizationId || !jobId) {
debug(`Cannot broadcast LLM update: missing organizationId or jobId`)
return
}

// Determine event name based on status
let eventName = "llm:job:update"
if (status === "completed" || status === "complete") {
eventName = "llm:job:complete"
} else if (status === "error" || status === "failed") {
eventName = "llm:job:error"
}

// Broadcast to organization room
const orgRoom = `llm/${organizationId}`
this.io.to(orgRoom).emit(eventName, update)
debug(`Broadcasted ${eventName} to room ${orgRoom} for job ${jobId}`)

// Also broadcast to conversation-specific room if available
if (conversationId) {
const convRoom = `llm/${organizationId}/${conversationId}`
this.io.to(convRoom).emit(eventName, update)
debug(`Broadcasted ${eventName} to room ${convRoom} for job ${jobId}`)
}
}
}

module.exports = (app) => new IoHandler(app)
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const debug = require("debug")(
`linto:components:MongoMigration:controllers:schema:conversationGenerations`,
)

module.exports = async function (db, collectionName) {
try {
if (!collectionName) return

const collection = db.collection(collectionName)

// Compound index for listing generations by conversation and service
await collection.createIndex(
{ conversationId: 1, serviceId: 1, createdAt: -1 },
{ name: "conversationId_serviceId_createdAt" },
)

// Unique index on generationId
await collection.createIndex(
{ generationId: 1 },
{ name: "generationId_unique", unique: true, sparse: true },
)

// Index on jobId for lookups
await collection.createIndex({ jobId: 1 }, { name: "jobId", sparse: true })

// Index for finding current generation
await collection.createIndex(
{ conversationId: 1, serviceId: 1, isCurrent: 1 },
{ name: "conversationId_serviceId_isCurrent" },
)

console.log(`Collection "${collectionName}" indexes created successfully.`)
} catch (error) {
console.error("Error creating indexes:", error)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Migration: Create conversationGenerations collection
*
* This collection tracks generation history per conversation + service combination.
* Each generation maps to a jobId in LLM Gateway.
*/
const initConversationGenerations = require(
`${process.cwd()}/components/MongoMigration/controllers/schema/conversationGenerations`,
)

const COLLECTION_NAME = "conversationGenerations"

module.exports = {
async up(db) {
// Check if collection exists
const collections = await db
.listCollections({ name: COLLECTION_NAME })
.toArray()
if (collections.length === 0) {
await db.createCollection(COLLECTION_NAME)
}

// Create indexes using schema helper
await initConversationGenerations(db, COLLECTION_NAME)
},

async down(db) {
const collections = await db
.listCollections({ name: COLLECTION_NAME })
.toArray()
if (collections.length > 0) {
await db.collection(COLLECTION_NAME).drop()
}
},
}
20 changes: 20 additions & 0 deletions studio-api/components/MongoMigration/version/1.6.2/version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const debug = require("debug")(
`linto:components:MongoMigration:controllers:version:1.6.2:version`,
)

const previous_version = "1.6.1"
const version = "1.6.2"

module.exports = {
async up(db) {
return db
.collection("version")
.updateMany({}, { $set: { version: version } })
},

async down(db) {
return db
.collection("version")
.updateMany({}, { $set: { version: previous_version } })
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ const algorithm = process.env.JWT_ALGORITHM || "HS256"

const DEFAULT_TOKEN_EXPIRATION = "1d"

function generateTokens(sessionId) {
const payload = { fromPublic: true, fromSession: sessionId, role: 0 }
function generateTokens(sessionId, organizationId = null) {
const payload = {
fromPublic: true,
fromSession: sessionId,
organizationId: organizationId,
role: 0,
}
const salt = sessionId

const authSecret = salt + process.env.CM_JWT_SECRET
Expand Down
2 changes: 1 addition & 1 deletion studio-api/components/WebServer/controllers/export/docx.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async function generateDocxOnFormat(query, conversationExport) {
conversationExport.convId,
["speakers", "name", "description", "owner", "locale", "metadata"],
)
data = {
const data = {
conversation: conversation[0],
speakers: conversation[0].speakers.map(
(speaker) => speaker.speaker_name + " : ",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const {
} = require("../content/documentComponents.js")

const generate = (docxContent, document) => {
document.doc.addSection(generateHeader(data.conversation.name))
document.doc.addSection(generateHeader(docxContent.filedata.title))

const columnProperties = textColumn(2, 500)
document.doc.addSection({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const {
} = require("../content/documentComponents.js")

const generate = (docxContent, document) => {
document.doc.addSection(generateHeader(data.conversation.name))
document.doc.addSection(generateHeader(docxContent.filedata.title))

const columnProperties = textColumn(2, 720)
document.doc.addSection({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const {
} = require("../content/documentComponents.js")

const generate = (docxContent, document) => {
document.doc.addSection(generateHeader(data.conversation.name))
document.doc.addSection(generateHeader(docxContent.filedata.title))

const columnProperties = textColumn(2, 500)
document.doc.addSection({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const {
} = require("../content/documentComponents.js")

const generate = (docxContent, document) => {
document.doc.addSection(generateHeader(data.conversation.name))
document.doc.addSection(generateHeader(docxContent.filedata.title))

const columnProperties = textColumn(2, 500)
document.doc.addSection({
Expand Down
Loading