feat: widgets and ai chat
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { ZAgentCreditsBody } from '@cio/utils/validation/agent';
|
||||
import { addCredits, getTokenBalance } from '@api/services/agent/usage';
|
||||
|
||||
/**
|
||||
* POST /agent/credits
|
||||
* Purchase additional token credits. Org admins only.
|
||||
* In v1, this directly adds credits (no payment integration).
|
||||
* Payment integration will be added when billing provider is wired up.
|
||||
*/
|
||||
export const agentCreditsRouter = new Hono().post(
|
||||
'/credits',
|
||||
authMiddleware,
|
||||
orgAdminMiddleware,
|
||||
zValidator('json', ZAgentCreditsBody),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { amount } = c.req.valid('json');
|
||||
|
||||
// TODO: Integrate with payment provider before adding credits.
|
||||
// For now, this endpoint adds credits directly for testing purposes.
|
||||
await addCredits(orgId, amount);
|
||||
const balance = await getTokenBalance(orgId);
|
||||
|
||||
return c.json({ success: true, data: balance });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to purchase credits');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import {
|
||||
ZAgentHistoryQuery,
|
||||
ZAgentConversationParam,
|
||||
ZAgentConversationCreateBody,
|
||||
ZAgentHistorySaveBody,
|
||||
ZAgentHistoryDeleteParam,
|
||||
ZAgentGenerateTitleParam,
|
||||
ZAgentGenerateTitleBody
|
||||
} from '@cio/utils/validation/agent';
|
||||
import { handleError, AppError } from '@api/utils/errors';
|
||||
import {
|
||||
listChatConversations,
|
||||
getChatConversation,
|
||||
createChatConversation,
|
||||
saveChatMessages,
|
||||
deleteChatConversation,
|
||||
updateConversationTitle
|
||||
} from '@api/services/agent/chat-history';
|
||||
import { generateConversationTitle } from '@api/services/agent/title-generation';
|
||||
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||
|
||||
export const agentHistoryRouter = new Hono()
|
||||
/**
|
||||
* GET /agent/history?courseId=...
|
||||
* List all conversations for the current user in a course.
|
||||
*/
|
||||
.get('/history', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentHistoryQuery), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { courseId } = c.req.valid('query');
|
||||
|
||||
const conversations = await listChatConversations(courseId, user.id);
|
||||
|
||||
return c.json({ success: true as const, data: conversations });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list conversations');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /agent/history/:conversationId
|
||||
* Get a single conversation with its messages.
|
||||
*/
|
||||
.get(
|
||||
'/history/:conversationId',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentConversationParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { conversationId } = c.req.valid('param');
|
||||
|
||||
const conversation = await getChatConversation(conversationId, user.id);
|
||||
|
||||
if (!conversation) {
|
||||
return c.json({ success: false, error: 'Conversation not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true as const, data: conversation });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch conversation');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /agent/history
|
||||
* Create a new conversation.
|
||||
*/
|
||||
.post(
|
||||
'/history',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('json', ZAgentConversationCreateBody),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { courseId, title } = c.req.valid('json');
|
||||
|
||||
const conversation = await createChatConversation(courseId, user.id, title);
|
||||
|
||||
return c.json({ success: true as const, data: conversation }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create conversation');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /agent/history/:conversationId
|
||||
* Save messages to a conversation.
|
||||
*/
|
||||
.put(
|
||||
'/history/:conversationId',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentConversationParam),
|
||||
zValidator('json', ZAgentHistorySaveBody),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { conversationId } = c.req.valid('param');
|
||||
const { messages, title } = c.req.valid('json');
|
||||
|
||||
await saveChatMessages(conversationId, user.id, messages, title);
|
||||
|
||||
return c.json({ success: true as const });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to save messages');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /agent/history/:conversationId
|
||||
* Delete a conversation.
|
||||
*/
|
||||
.delete(
|
||||
'/history/:conversationId',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentHistoryDeleteParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { conversationId } = c.req.valid('param');
|
||||
|
||||
await deleteChatConversation(conversationId, user.id);
|
||||
|
||||
return c.json({ success: true as const });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete conversation');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /agent/history/:conversationId/generate-title
|
||||
* Generate a short title from the first user message using a cheap model.
|
||||
*/
|
||||
.post(
|
||||
'/history/:conversationId/generate-title',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentGenerateTitleParam),
|
||||
zValidator('json', ZAgentGenerateTitleBody),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { conversationId } = c.req.valid('param');
|
||||
const { firstMessageText } = c.req.valid('json');
|
||||
|
||||
const providerConfig = pickAnyConfiguredProvider();
|
||||
if (!providerConfig) {
|
||||
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const title = await generateConversationTitle(firstMessageText, providerConfig);
|
||||
|
||||
await updateConversationTitle(conversationId, user.id, title);
|
||||
|
||||
return c.json({ success: true as const, data: { title } });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to generate title');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
export { agentStatusRouter } from './status';
|
||||
export { agentChatRouter } from './chat';
|
||||
export { agentUploadRouter } from './upload';
|
||||
export { agentUsageRouter } from './usage';
|
||||
export { agentCreditsRouter } from './credits';
|
||||
export { agentHistoryRouter } from './history';
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { ZAgentStatusQuery } from '@cio/utils/validation/agent';
|
||||
import { getTokenBalance } from '@api/services/agent/usage';
|
||||
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import { AgentRole, type AgentStatus } from '@cio/ai-assistant';
|
||||
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||
|
||||
/**
|
||||
* GET /agent/status
|
||||
* Returns whether AI assistant is enabled, the user's role, and usage info.
|
||||
* Used by the dashboard to show/hide the AI button and configure the UI.
|
||||
*/
|
||||
export const agentStatusRouter = new Hono().get(
|
||||
'/status',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('query', ZAgentStatusQuery),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { courseId } = c.req.valid('query');
|
||||
|
||||
// Check if AI is configured (self-hosted check) — enabled when any provider key is set
|
||||
const providerConfig = pickAnyConfiguredProvider();
|
||||
if (!providerConfig) {
|
||||
const status: AgentStatus = {
|
||||
enabled: false,
|
||||
role: AgentRole.STUDENT,
|
||||
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
|
||||
};
|
||||
|
||||
return c.json({ success: true, data: status });
|
||||
}
|
||||
|
||||
// Detect role
|
||||
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
||||
|
||||
// Get usage
|
||||
const usage = await getTokenBalance(orgId);
|
||||
|
||||
const status: AgentStatus = {
|
||||
enabled: true,
|
||||
role,
|
||||
usage
|
||||
};
|
||||
|
||||
return c.json({ success: true, data: status });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get agent status');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { handleError, AppError } from '@api/utils/errors';
|
||||
import { parseAndStoreDocument } from '@api/services/agent/document';
|
||||
import { isOrgOnPaidPlan } from '@api/services/agent/usage';
|
||||
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import { getChatConversation } from '@cio/db/queries/agent';
|
||||
import { redis } from '@api/utils/redis/redis';
|
||||
|
||||
/**
|
||||
* POST /agent/upload
|
||||
* Upload a PDF, DOCX, or PPTX file for AI-powered course plan generation.
|
||||
* EARLY_ADOPTER+ plan required. Max 5MB.
|
||||
*/
|
||||
export const agentUploadRouter = new Hono().post('/upload', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
|
||||
const courseId = c.req.query('courseId');
|
||||
if (!courseId) {
|
||||
throw new AppError('Course ID is required', 'COURSE_ID_REQUIRED', 400);
|
||||
}
|
||||
|
||||
const conversationId = c.req.query('conversationId');
|
||||
if (!conversationId) {
|
||||
throw new AppError('Conversation ID is required', 'CONVERSATION_ID_REQUIRED', 400);
|
||||
}
|
||||
|
||||
// Verify course team membership
|
||||
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||
if (!isTeamMember) {
|
||||
throw new AppError('You must be a course team member to upload documents', 'NOT_COURSE_TEAM_MEMBER', 403);
|
||||
}
|
||||
|
||||
// Verify the conversation exists and belongs to this user + course
|
||||
const conversation = await getChatConversation(conversationId, user.id);
|
||||
if (!conversation || conversation.courseId !== courseId) {
|
||||
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
// Plan gate — EARLY_ADOPTER+ required
|
||||
const isPaid = await isOrgOnPaidPlan(orgId);
|
||||
if (!isPaid) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'document_upload_requires_upgrade',
|
||||
upgradeRequired: true
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
// Parse multipart body
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file;
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new AppError('File is required', 'FILE_REQUIRED', 400);
|
||||
}
|
||||
|
||||
const result = await parseAndStoreDocument(file, orgId, user.id, courseId, conversationId, redis);
|
||||
|
||||
return c.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
if (error.statusCode === 413) {
|
||||
return c.json({ success: false, error: 'file_too_large', maxSize: 5242880 }, 413);
|
||||
}
|
||||
|
||||
if (error.statusCode === 415) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'unsupported_file_type',
|
||||
allowed: [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
]
|
||||
},
|
||||
415
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return handleError(c, error, 'Failed to upload document');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { getDetailedUsage } from '@api/services/agent/usage';
|
||||
|
||||
/**
|
||||
* GET /agent/usage
|
||||
* Returns detailed usage stats for the current org:
|
||||
* used, allowance, creditBalance, remaining, and daily history for the current month.
|
||||
*/
|
||||
export const agentUsageRouter = new Hono().get('/usage', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const usage = await getDetailedUsage(orgId);
|
||||
|
||||
return c.json({ success: true, data: usage });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch usage stats');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user