Ctrl K
ComunidadGuías…

server/api/AiRoute.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
const homeSuggestions = require("../modules/ai/homeSuggestions/service");
const { getObservationAccess } = require("../modules/observations/access");
const rateLimit = require("express-rate-limit");

const db = require("../models/models");
const connectionSetup = require("../modules/ai/connectionSetup");
const memory = require("../modules/ai/memory");
const {
  assignStudioChart,
  getOrchestration,
  placeChartPreview,
  respond,
  promoteSession,
  getAvailableTools,
  getContextOptions,
  getConversations,
  getConversation,
  deleteConversation,
  getAiUsage
} = require("../controllers/AiController");
const verifyToken = require("../modules/verifyToken");
const TeamController = require("../controllers/TeamController");
const {
  routeWorkspaceRequest,
} = require("../modules/ai/orchestrator/runtime/deterministicRouter");
const { getRoleBoundaryMessage } = require("../modules/ai/orchestrator/rolePolicy");
const {
  TEAM_AI_DISABLED_MESSAGE,
  getWorkspaceOrchestratorPolicy,
} = require("../modules/workspaceContext/policy");

const apiLimiter = (max = 10) => {
  return rateLimit({
    windowMs: 60 * 1000, // 1 minute
    max,
  });
};

function sendAiError(res, error) {
  const statusCode = Number(error?.statusCode);
  if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 500) {
    return res.status(statusCode).json({ error: error.message });
  }
  return res.status(500).json({
    error: "Chartbrew could not complete this request. Try again.",
  });
}

const checkAccess = async (req, res, next) => {
  try {
    const teamId = req.params?.teamId || req.body?.teamId || req.query?.teamId;

    if (!teamId) {
      return res.status(400).json({ error: "teamId is required" });
    }

    const teamController = new TeamController();
    const [teamRole, team] = await Promise.all([
      teamController.getTeamRole(teamId, req.user.id),
      db.Team.findByPk(teamId, { attributes: ["aiEnabled"] }),
    ]);

    if (!teamRole?.role || !team) {
      return res.status(403).json({ error: "Access denied" });
    }

    req.aiTeamRole = teamRole;
    req.aiTeamEnabled = team.aiEnabled !== false;
    return next();
  } catch (_error) {
    return res.status(500).json({
      error: "Chartbrew could not check workspace access. Try again.",
    });
  }
};

const requireTeamAiEnabled = (req, res, next) => {
  if (!req.aiTeamEnabled) {
    return res.status(403).json({ error: TEAM_AI_DISABLED_MESSAGE });
  }
  return next();
};

const checkAdminAccess = (req, res, next) => {
  if (!["teamOwner", "teamAdmin"].includes(req.aiTeamRole?.role)) {
    return res.status(403).json({ error: "Access denied" });
  }
  return next();
};

const isOpenAiApiKeySet = () => process.env.CB_OPENAI_API_KEY;

module.exports = (app) => {
  app.get("/ai/availability", apiLimiter(20), verifyToken, checkAccess, (req, res) => {
    const platformEnabled = getWorkspaceOrchestratorPolicy().enabled;
    const enabled = platformEnabled && req.aiTeamEnabled;
    let disabledBy = null;
    if (!enabled) disabledBy = platformEnabled ? "team" : "platform";
    return res.json({
      disabledBy,
      enabled,
    });
  });

  app.post("/ai/respond", apiLimiter(3), verifyToken, checkAccess, requireTeamAiEnabled, async (req, res) => {
    const {
      activeChartId,
      action,
      aiConversationId,
      context,
      message,
      persistence,
      sessionId,
      teamId,
    } = req.body;

    if (!teamId || !req.user.id) {
      return res.status(400).json({ error: "teamId and user ID are required" });
    }
    const localRoute = routeWorkspaceRequest({ action, message });
    const canUseLocalWorkspaceRoute = memory.isRememberCommand(message)
      || ["executor", "fast_path"].includes(localRoute?.mode)
      || localRoute?.intent === "workspace_follow_up"
      || Boolean(getRoleBoundaryMessage(req.aiTeamRole.role, message));
    if (getWorkspaceOrchestratorPolicy().enabled
      && !action
      && !canUseLocalWorkspaceRoute
      && !isOpenAiApiKeySet()) {
      return res.status(400).json({ error: "Ask your data is not configured for this workspace" });
    }

    try {
      const orchestration = await respond({
        activeChartId,
        action,
        aiConversationId,
        context,
        message,
        persistence,
        sessionId,
        teamId,
        userId: req.user.id,
      });
      return res.json({ orchestration });
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  app.post("/ai/chart-previews/place", apiLimiter(20), verifyToken, checkAccess, async (req, res) => {
    const {
      action, aiConversationId, persistence, sessionId, teamId,
    } = req.body;
    try {
      const chartPreview = await placeChartPreview({
        action,
        aiConversationId,
        persistence,
        sessionId,
        teamId,
        userId: req.user.id,
      });
      return res.json({ chartPreview });
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  app.route("/ai/connections/setup")
    .all(apiLimiter(20), verifyToken, checkAccess)
    .get(async (req, res) => {
      try {
        const option = await connectionSetup({
          teamId: req.query.teamId, conversationId: req.query.conversationId,
          providerId: req.query.providerId, userId: req.user.id,
        });
        return res.json(option);
      } catch (error) { return sendAiError(res, error); }
    })
    .post(async (req, res) => {
      try {
        const option = await connectionSetup({
          teamId: req.body.teamId, conversationId: req.body.conversationId,
          providerId: req.body.providerId, userId: req.user.id, create: true,
        });
        return res.json(option);
      } catch (error) { return sendAiError(res, error); }
    });

  app.post(
    "/ai/sessions/:sessionId/promote",
    apiLimiter(5),
    verifyToken,
    checkAccess,
    async (req, res) => {
      try {
        const result = await promoteSession({
          sessionId: req.params.sessionId,
          teamId: req.body.teamId,
          userId: req.user.id,
        });
        return res.json(result);
      } catch (error) {
        return sendAiError(res, error);
      }
    },
  );

  // Main orchestration endpoint - handles conversation creation/loading automatically
  app.post("/ai/orchestrate", apiLimiter(3), verifyToken, checkAccess, requireTeamAiEnabled, async (req, res) => {
    const {
      question,
      conversationHistory = [],
      aiConversationId,
      teamId,
      context
    } = req.body;

    if (!teamId || !req.user.id) {
      return res.status(400).json({ error: "teamId and user ID are required" });
    }

    const localRoute = routeWorkspaceRequest({ message: question });
    const canUseLocalWorkspaceRoute = memory.isRememberCommand(question)
      || localRoute?.mode === "fast_path"
      || localRoute?.intent === "workspace_follow_up"
      || Boolean(getRoleBoundaryMessage(req.aiTeamRole.role, question));
    if (getWorkspaceOrchestratorPolicy().enabled
      && !canUseLocalWorkspaceRoute
      && !isOpenAiApiKeySet()) {
      return res.status(400).json({ error: "Ask your data is not configured for this workspace" });
    }

    try {
      const orchestration = await getOrchestration(
        teamId, question, conversationHistory, aiConversationId, req.user.id, context
      );
      return res.json({ orchestration });
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  app.route("/ai/memory")
    .all(apiLimiter(60), verifyToken, checkAccess)
    .get(async (req, res) => {
      try {
        return res.json({
          memories: await memory.listMemories(req.query.teamId, req.user.id),
          recentWork: await homeSuggestions.readRecentWork(
            await getObservationAccess(req.query.teamId, req.user.id)
          ),
          sharingEnabled: req.aiTeamEnabled && getWorkspaceOrchestratorPolicy().enabled
            && getWorkspaceOrchestratorPolicy().externalLearningContextEnabled,
        });
      } catch (error) { return sendAiError(res, error); }
    })
    .post(async (req, res) => {
      try {
        const result = await memory.changeMemory({ teamId: req.body.teamId, userId: req.user.id, text: req.body.text });
        return res.status(201).json({ memory: result });
      } catch (error) { return sendAiError(res, error); }
    })
    .delete(async (req, res) => {
      try {
        await memory.changeMemory({ teamId: req.query.teamId, userId: req.user.id, remove: true });
        return res.json({ success: true });
      } catch (error) { return sendAiError(res, error); }
    });
  app.route("/ai/memory/:memoryId")
    .all(apiLimiter(60), verifyToken, checkAccess)
    .patch(async (req, res) => {
      try {
        const result = await memory.changeMemory({ teamId: req.body.teamId, userId: req.user.id, id: req.params.memoryId, text: req.body.text });
        return res.json({ memory: result });
      } catch (error) { return sendAiError(res, error); }
    })
    .delete(async (req, res) => {
      try {
        await memory.changeMemory({ teamId: req.query.teamId, userId: req.user.id, id: req.params.memoryId, remove: true });
        return res.json({ success: true });
      } catch (error) { return sendAiError(res, error); }
    });

  // Get available tools
  app.get("/ai/tools", apiLimiter(10), verifyToken, checkAccess, checkAdminAccess, async (req, res) => {
    try {
      const tools = await getAvailableTools();
      res.json({ tools });
    } catch (error) {
      sendAiError(res, error);
    }
  });

  app.get("/ai/context", apiLimiter(20), verifyToken, checkAccess, async (req, res) => {
    try {
      const context = await getContextOptions(req.query.teamId, req.user.id, {
        limit: req.query.limit,
        query: req.query.query,
        type: req.query.type,
      });
      return res.json({ context });
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  // Get user conversations for a team
  app.get("/ai/conversations", apiLimiter(10), verifyToken, checkAccess, async (req, res) => {
    const {
      teamId, limit = 20, offset = 0, studioChartId
    } = req.query;

    if (!teamId || !req.user.id) {
      return res.status(400).json({ error: "teamId and userId are required" });
    }

    try {
      const conversations = await getConversations(
        teamId, req.user.id, Number(limit), Number(offset), studioChartId
      );
      return res.json({ conversations });
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  // Get a specific conversation
  app.get("/ai/conversations/:conversationId", apiLimiter(20), verifyToken, checkAccess, async (req, res) => {
    const { conversationId } = req.params;
    const { teamId } = req.query;

    if (!teamId) {
      return res.status(400).json({ error: "teamId is required" });
    }

    try {
      const conversation = await getConversation(conversationId, teamId, req.user.id);
      return res.json({ conversation });
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  app.patch("/ai/conversations/:conversationId/studio", apiLimiter(10), verifyToken, checkAccess, async (req, res) => {
    try {
      const conversation = await assignStudioChart(
        req.params.conversationId, req.body.teamId, req.user.id, req.body.chartId
      );
      return res.json({ conversation });
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  // Delete a conversation
  app.delete("/ai/conversations/:conversationId", apiLimiter(10), verifyToken, checkAccess, async (req, res) => {
    const { conversationId } = req.params;
    const { teamId } = req.query;

    if (!teamId) {
      return res.status(400).json({ error: "teamId is required" });
    }

    try {
      const result = await deleteConversation(conversationId, teamId, req.user.id);
      return res.json(result);
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  // Get team usage statistics (for billing/analytics)
  app.get("/ai/usage/:teamId", apiLimiter(20), verifyToken, checkAccess, checkAdminAccess, async (req, res) => {
    const { teamId } = req.params;
    const { startDate, endDate } = req.query;

    if (!teamId) {
      return res.status(400).json({ error: "teamId is required" });
    }

    try {
      const usage = await getAiUsage(teamId, startDate, endDate);
      return res.json(usage);
    } catch (error) {
      return sendAiError(res, error);
    }
  });

  return (req, res, next) => {
    next();
  };
};