mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* feat: enforce RBAC and harden collaboration features - Mount requirePermission on hosts/snippets/credentials/automations/AI routes - Seed and backfill system role permissions on every dialect at startup - Support personal credential overrides for RDP/VNC/Telnet shared hosts - Broadcast participant presence in shared terminal sessions - Make audit log forwarding configurable from the admin panel - Add role members endpoint and snippet folder sharing * fix: enforce RBAC across split routes
991 lines
28 KiB
TypeScript
991 lines
28 KiB
TypeScript
import { getErrorMessage } from "../utils/error-message.js";
|
|
import express from "express";
|
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
|
import { PermissionManager } from "../utils/permission-manager.js";
|
|
import { AuthManager } from "../utils/auth-manager.js";
|
|
import { databaseLogger } from "../utils/logger.js";
|
|
import {
|
|
getAuditUsername,
|
|
getRequestMeta,
|
|
logAudit,
|
|
} from "../utils/audit-logger.js";
|
|
import {
|
|
createCurrentAiRepository,
|
|
createCurrentHostRepository,
|
|
} from "../database/repositories/factory.js";
|
|
import { buildSystemPrompt } from "./context.js";
|
|
import { runAgent } from "./engine.js";
|
|
import {
|
|
createAiGate,
|
|
isAiGloballyEnabled,
|
|
resolveAiAccess,
|
|
} from "./gating.js";
|
|
import {
|
|
FALLBACK_MODELS,
|
|
getAdapter,
|
|
REQUIRES_API_KEY,
|
|
REQUIRES_BASE_URL,
|
|
} from "./providers/registry.js";
|
|
import type {
|
|
AiProviderType,
|
|
ChatMessage,
|
|
ProviderConfig,
|
|
} from "./providers/types.js";
|
|
import { isAiProviderType } from "./providers/types.js";
|
|
import { applyProposal } from "./tools/executor.js";
|
|
|
|
const router = express.Router();
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const permissionManager = PermissionManager.getInstance();
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
const aiGate = createAiGate();
|
|
|
|
function parseId(raw: unknown): number | null {
|
|
const id = typeof raw === "string" ? parseInt(raw, 10) : Number(raw);
|
|
return Number.isInteger(id) && id > 0 ? id : null;
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/status:
|
|
* get:
|
|
* summary: Whether the AI assistant is available to this user
|
|
* description: >
|
|
* Deliberately not behind the AI gate: the frontend calls this to decide
|
|
* whether to render any AI surface at all, and needs a plain answer rather
|
|
* than a 403 when the feature is off.
|
|
* tags:
|
|
* - AI
|
|
* responses:
|
|
* 200:
|
|
* description: The effective enablement state.
|
|
*/
|
|
router.get("/status", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
try {
|
|
const [globalEnabled, access] = await Promise.all([
|
|
isAiGloballyEnabled(),
|
|
resolveAiAccess(userId),
|
|
]);
|
|
res.json({
|
|
globallyEnabled: globalEnabled,
|
|
enabled: access.enabled,
|
|
allowReadOnlyCommands: access.allowReadOnlyCommands,
|
|
});
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to read AI status", err, {
|
|
operation: "ai_status_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to read AI status" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/providers:
|
|
* get:
|
|
* summary: List the user's configured AI providers
|
|
* tags:
|
|
* - AI
|
|
* responses:
|
|
* 200:
|
|
* description: Providers, with API keys masked.
|
|
* 403:
|
|
* description: The AI assistant is not enabled.
|
|
*/
|
|
router.get(
|
|
"/providers",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.use"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
try {
|
|
const providers = await createCurrentAiRepository().listProviders(userId);
|
|
res.json({ providers });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to list AI providers", err, {
|
|
operation: "ai_providers_list_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to list providers" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/providers:
|
|
* post:
|
|
* summary: Add an AI provider
|
|
* tags:
|
|
* - AI
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* providerType:
|
|
* type: string
|
|
* label:
|
|
* type: string
|
|
* baseUrl:
|
|
* type: string
|
|
* apiKey:
|
|
* type: string
|
|
* defaultModel:
|
|
* type: string
|
|
* responses:
|
|
* 201:
|
|
* description: Provider created.
|
|
* 400:
|
|
* description: Invalid request body.
|
|
*/
|
|
router.post(
|
|
"/providers",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.manage_providers"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const { providerType, label, baseUrl, apiKey, defaultModel } =
|
|
req.body ?? {};
|
|
|
|
if (!isAiProviderType(providerType)) {
|
|
return res.status(400).json({ error: "Unknown provider type" });
|
|
}
|
|
if (typeof label !== "string" || !label.trim()) {
|
|
return res.status(400).json({ error: "label is required" });
|
|
}
|
|
if (REQUIRES_BASE_URL.includes(providerType) && !baseUrl?.trim()) {
|
|
return res.status(400).json({ error: "This provider needs a base URL" });
|
|
}
|
|
if (REQUIRES_API_KEY.includes(providerType) && !apiKey?.trim()) {
|
|
return res.status(400).json({ error: "This provider needs an API key" });
|
|
}
|
|
|
|
try {
|
|
const created = await createCurrentAiRepository().createProvider({
|
|
userId,
|
|
providerType,
|
|
label: label.trim(),
|
|
baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null,
|
|
apiKey: typeof apiKey === "string" ? apiKey.trim() : null,
|
|
defaultModel:
|
|
typeof defaultModel === "string" ? defaultModel.trim() : null,
|
|
});
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "create_ai_provider",
|
|
resourceType: "ai_provider",
|
|
resourceId: String(created.id),
|
|
resourceName: created.label,
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
res.status(201).json({ provider: created });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to create AI provider", err, {
|
|
operation: "ai_provider_create_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to create provider" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/providers/{id}:
|
|
* patch:
|
|
* summary: Update an AI provider
|
|
* tags:
|
|
* - AI
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Provider updated.
|
|
* 404:
|
|
* description: Provider not found.
|
|
*/
|
|
router.patch(
|
|
"/providers/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.manage_providers"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const id = parseId(req.params.id);
|
|
if (!id) return res.status(400).json({ error: "Invalid provider id" });
|
|
|
|
try {
|
|
const updated = await createCurrentAiRepository().updateProvider(
|
|
id,
|
|
userId,
|
|
req.body ?? {},
|
|
);
|
|
if (!updated)
|
|
return res.status(404).json({ error: "Provider not found" });
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "update_ai_provider",
|
|
resourceType: "ai_provider",
|
|
resourceId: String(id),
|
|
resourceName: updated.label,
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
res.json({ provider: updated });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to update AI provider", err, {
|
|
operation: "ai_provider_update_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to update provider" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/providers/{id}:
|
|
* delete:
|
|
* summary: Delete an AI provider
|
|
* tags:
|
|
* - AI
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Provider deleted.
|
|
* 404:
|
|
* description: Provider not found.
|
|
*/
|
|
router.delete(
|
|
"/providers/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.manage_providers"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const id = parseId(req.params.id);
|
|
if (!id) return res.status(400).json({ error: "Invalid provider id" });
|
|
|
|
try {
|
|
const deleted = await createCurrentAiRepository().deleteProvider(
|
|
id,
|
|
userId,
|
|
);
|
|
if (!deleted)
|
|
return res.status(404).json({ error: "Provider not found" });
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "delete_ai_provider",
|
|
resourceType: "ai_provider",
|
|
resourceId: String(id),
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to delete AI provider", err, {
|
|
operation: "ai_provider_delete_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to delete provider" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/probe-models:
|
|
* post:
|
|
* summary: List models for a provider that has not been saved yet
|
|
* description: >
|
|
* Lets the add-provider form fill its model picker before the provider
|
|
* exists, so nobody has to go and look up model names by hand.
|
|
* tags:
|
|
* - AI
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* providerType:
|
|
* type: string
|
|
* baseUrl:
|
|
* type: string
|
|
* apiKey:
|
|
* type: string
|
|
* providerId:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Model ids, possibly a curated fallback list.
|
|
* 400:
|
|
* description: Unknown provider type.
|
|
*/
|
|
router.post(
|
|
"/probe-models",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.manage_providers"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const { providerType, baseUrl, apiKey, providerId } = req.body ?? {};
|
|
|
|
if (!isAiProviderType(providerType)) {
|
|
return res.status(400).json({ error: "Unknown provider type" });
|
|
}
|
|
|
|
try {
|
|
// Editing an existing provider sends no key, so fall back to the stored
|
|
// one rather than making the user retype it just to refresh the list.
|
|
let resolvedKey =
|
|
typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : null;
|
|
if (!resolvedKey && parseId(providerId)) {
|
|
const stored = await createCurrentAiRepository().findProviderWithSecret(
|
|
parseId(providerId) as number,
|
|
userId,
|
|
);
|
|
resolvedKey = stored?.apiKey ?? null;
|
|
}
|
|
|
|
const models = await getAdapter(providerType).listModels({
|
|
providerType,
|
|
baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null,
|
|
apiKey: resolvedKey,
|
|
});
|
|
|
|
res.json({ models, source: "live" });
|
|
} catch (err) {
|
|
// A provider that cannot be reached yet still gets a usable list, so the
|
|
// form is never a blank text box the user has to guess into.
|
|
const fallback = FALLBACK_MODELS[providerType as AiProviderType] ?? [];
|
|
res.json({
|
|
models: fallback,
|
|
source: fallback.length ? "fallback" : "none",
|
|
warning: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/providers/{id}/models:
|
|
* get:
|
|
* summary: List models available from a provider
|
|
* tags:
|
|
* - AI
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Model ids.
|
|
* 502:
|
|
* description: The provider could not be reached.
|
|
*/
|
|
router.get(
|
|
"/providers/:id/models",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.use"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const id = parseId(req.params.id);
|
|
if (!id) return res.status(400).json({ error: "Invalid provider id" });
|
|
|
|
try {
|
|
const provider = await createCurrentAiRepository().findProviderWithSecret(
|
|
id,
|
|
userId,
|
|
);
|
|
if (!provider)
|
|
return res.status(404).json({ error: "Provider not found" });
|
|
|
|
const models = await getAdapter(provider.providerType).listModels({
|
|
providerType: provider.providerType as ProviderConfig["providerType"],
|
|
baseUrl: provider.baseUrl,
|
|
apiKey: provider.apiKey,
|
|
});
|
|
res.json({ models });
|
|
} catch (err) {
|
|
// The message can carry the allowlist hint, which the user needs to act on.
|
|
const message = getErrorMessage(err, "Could not reach the provider");
|
|
databaseLogger.warn("Failed to list provider models", {
|
|
operation: "ai_provider_models_failed",
|
|
userId,
|
|
});
|
|
res.status(502).json({ error: message });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/conversations:
|
|
* get:
|
|
* summary: List the user's AI conversations
|
|
* tags:
|
|
* - AI
|
|
* responses:
|
|
* 200:
|
|
* description: Conversations, newest first.
|
|
*/
|
|
router.get(
|
|
"/conversations",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.use"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
try {
|
|
const conversations =
|
|
await createCurrentAiRepository().listConversations(userId);
|
|
res.json({ conversations });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to list AI conversations", err, {
|
|
operation: "ai_conversations_list_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to list conversations" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/conversations/{id}:
|
|
* get:
|
|
* summary: Get one conversation with its messages and proposals
|
|
* tags:
|
|
* - AI
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: The conversation.
|
|
* 404:
|
|
* description: Conversation not found.
|
|
*/
|
|
router.get(
|
|
"/conversations/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.use"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const id = parseId(req.params.id);
|
|
if (!id) return res.status(400).json({ error: "Invalid conversation id" });
|
|
|
|
try {
|
|
const repository = createCurrentAiRepository();
|
|
const conversation = await repository.findConversation(id, userId);
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
|
|
const [messages, proposals] = await Promise.all([
|
|
repository.listMessages(id),
|
|
repository.listProposals(userId, id),
|
|
]);
|
|
|
|
res.json({ conversation, messages, proposals });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to load AI conversation", err, {
|
|
operation: "ai_conversation_load_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to load conversation" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/conversations/{id}:
|
|
* delete:
|
|
* summary: Delete a conversation
|
|
* tags:
|
|
* - AI
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Conversation deleted.
|
|
*/
|
|
router.delete(
|
|
"/conversations/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.use"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const id = parseId(req.params.id);
|
|
if (!id) return res.status(400).json({ error: "Invalid conversation id" });
|
|
|
|
try {
|
|
const deleted = await createCurrentAiRepository().deleteConversation(
|
|
id,
|
|
userId,
|
|
);
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to delete AI conversation", err, {
|
|
operation: "ai_conversation_delete_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to delete conversation" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/chat/stream:
|
|
* post:
|
|
* summary: Send a message and stream the assistant's reply
|
|
* description: >
|
|
* Server-sent events. Emits token, tool_call, tool_result, proposal,
|
|
* done and error frames.
|
|
* tags:
|
|
* - AI
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* conversationId:
|
|
* type: integer
|
|
* providerId:
|
|
* type: integer
|
|
* model:
|
|
* type: string
|
|
* message:
|
|
* type: string
|
|
* activeTab:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: An event stream.
|
|
* 400:
|
|
* description: Invalid request body.
|
|
*/
|
|
router.post(
|
|
"/chat/stream",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.use"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const { conversationId, providerId, model, message, activeTab } =
|
|
req.body ?? {};
|
|
|
|
if (typeof message !== "string" || !message.trim()) {
|
|
return res.status(400).json({ error: "message is required" });
|
|
}
|
|
|
|
const resolvedProviderId = parseId(providerId);
|
|
if (!resolvedProviderId) {
|
|
return res.status(400).json({ error: "providerId is required" });
|
|
}
|
|
|
|
const repository = createCurrentAiRepository();
|
|
|
|
try {
|
|
const provider = await repository.findProviderWithSecret(
|
|
resolvedProviderId,
|
|
userId,
|
|
);
|
|
if (!provider) {
|
|
return res.status(404).json({ error: "Provider not found" });
|
|
}
|
|
|
|
const chosenModel =
|
|
(typeof model === "string" && model.trim()) ||
|
|
provider.defaultModel ||
|
|
"";
|
|
if (!chosenModel) {
|
|
return res.status(400).json({ error: "No model selected" });
|
|
}
|
|
|
|
// Resolve or create the conversation before the stream opens, so a
|
|
// failure here is still a normal JSON error the client can render.
|
|
let conversation = conversationId
|
|
? await repository.findConversation(Number(conversationId), userId)
|
|
: null;
|
|
if (!conversation) {
|
|
conversation = await repository.createConversation({
|
|
userId,
|
|
title: message.trim().slice(0, 60),
|
|
providerId: resolvedProviderId,
|
|
model: chosenModel,
|
|
});
|
|
}
|
|
|
|
const history = await repository.listMessages(conversation.id);
|
|
await repository.appendMessage({
|
|
conversationId: conversation.id,
|
|
role: "user",
|
|
content: message.trim(),
|
|
});
|
|
|
|
const access = await resolveAiAccess(userId);
|
|
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-store, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
res.flushHeaders?.();
|
|
|
|
const heartbeat = setInterval(() => {
|
|
try {
|
|
res.write(": keepalive\n\n");
|
|
} catch {
|
|
clearInterval(heartbeat);
|
|
}
|
|
}, 30000);
|
|
|
|
const abort = new AbortController();
|
|
req.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
abort.abort();
|
|
});
|
|
|
|
const send = (event: unknown) => {
|
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
};
|
|
|
|
send({ type: "conversation", conversationId: conversation.id });
|
|
|
|
const chatHistory: ChatMessage[] = history.map((entry) => ({
|
|
role: entry.role as ChatMessage["role"],
|
|
content: entry.content,
|
|
...(entry.toolCalls ? { toolCalls: JSON.parse(entry.toolCalls) } : {}),
|
|
}));
|
|
chatHistory.push({ role: "user", content: message.trim() });
|
|
|
|
let assistantText = "";
|
|
let assistantToolCalls: unknown[] = [];
|
|
|
|
try {
|
|
for await (const event of runAgent({
|
|
config: {
|
|
providerType:
|
|
provider.providerType as ProviderConfig["providerType"],
|
|
baseUrl: provider.baseUrl,
|
|
apiKey: provider.apiKey,
|
|
},
|
|
model: chosenModel,
|
|
system: buildSystemPrompt({
|
|
hostCount: hosts.length,
|
|
activeTab: typeof activeTab === "string" ? activeTab : null,
|
|
allowReadOnlyCommands: access.allowReadOnlyCommands,
|
|
}),
|
|
history: chatHistory,
|
|
context: {
|
|
userId,
|
|
conversationId: conversation.id,
|
|
allowReadOnlyCommands: access.allowReadOnlyCommands,
|
|
},
|
|
signal: abort.signal,
|
|
})) {
|
|
if (event.type === "assistant_message") {
|
|
assistantText = event.content;
|
|
// Kept so the next message replays them verbatim. Gemini rejects a
|
|
// turn whose functionCall parts lost their thoughtSignature, so
|
|
// dropping these breaks the second message in every conversation.
|
|
assistantToolCalls = event.toolCalls;
|
|
continue;
|
|
}
|
|
|
|
if (event.type === "proposal") {
|
|
const stored = await repository.createProposal({
|
|
conversationId: conversation.id,
|
|
userId,
|
|
kind: event.draft.kind,
|
|
summary: event.draft.summary,
|
|
payload: JSON.stringify(event.draft.payload),
|
|
});
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "ai_proposal_created",
|
|
resourceType: "ai_proposal",
|
|
resourceId: String(stored.id),
|
|
resourceName: event.draft.kind,
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
send({ type: "proposal", proposal: stored });
|
|
continue;
|
|
}
|
|
|
|
send(event);
|
|
}
|
|
} finally {
|
|
clearInterval(heartbeat);
|
|
}
|
|
|
|
if (assistantText || assistantToolCalls.length) {
|
|
await repository.appendMessage({
|
|
conversationId: conversation.id,
|
|
role: "assistant",
|
|
content: assistantText,
|
|
toolCalls: assistantToolCalls.length
|
|
? JSON.stringify(assistantToolCalls)
|
|
: null,
|
|
});
|
|
}
|
|
await repository.touchConversation(conversation.id);
|
|
|
|
send({ type: "done" });
|
|
res.end();
|
|
} catch (err) {
|
|
databaseLogger.error("AI chat stream failed", err, {
|
|
operation: "ai_chat_stream_failed",
|
|
userId,
|
|
});
|
|
if (res.headersSent) {
|
|
res.write(
|
|
`data: ${JSON.stringify({ type: "error", message: "The assistant stopped unexpectedly" })}\n\n`,
|
|
);
|
|
res.end();
|
|
} else {
|
|
res.status(500).json({ error: "Failed to start the assistant" });
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/proposals/{id}/apply:
|
|
* post:
|
|
* summary: Apply a pending proposal
|
|
* description: >
|
|
* Re-validates the stored payload and dispatches it through the same
|
|
* repository logic a manual action uses.
|
|
* tags:
|
|
* - AI
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Proposal applied.
|
|
* 400:
|
|
* description: The proposal could not be applied.
|
|
* 404:
|
|
* description: Proposal not found.
|
|
*/
|
|
router.post(
|
|
"/proposals/:id/apply",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.apply_proposals"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const id = parseId(req.params.id);
|
|
if (!id) return res.status(400).json({ error: "Invalid proposal id" });
|
|
|
|
const repository = createCurrentAiRepository();
|
|
|
|
try {
|
|
const stored = await repository.findProposal(id, userId);
|
|
if (!stored) return res.status(404).json({ error: "Proposal not found" });
|
|
if (stored.status !== "pending") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: `This proposal was already ${stored.status}` });
|
|
}
|
|
|
|
let payload: Record<string, unknown>;
|
|
try {
|
|
payload = JSON.parse(stored.payload) as Record<string, unknown>;
|
|
} catch {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "The proposal payload is invalid" });
|
|
}
|
|
|
|
const result = await applyProposal(stored.kind, payload, userId);
|
|
await repository.setProposalStatus(id, userId, "applied", result.summary);
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "ai_proposal_applied",
|
|
resourceType: "ai_proposal",
|
|
resourceId: String(id),
|
|
resourceName: stored.kind,
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
res.json({ success: result.ok, summary: result.summary });
|
|
} catch (err) {
|
|
const message = getErrorMessage(err, "Failed to apply the proposal");
|
|
databaseLogger.error("Failed to apply AI proposal", err, {
|
|
operation: "ai_proposal_apply_failed",
|
|
userId,
|
|
});
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "ai_proposal_applied",
|
|
resourceType: "ai_proposal",
|
|
resourceId: String(id),
|
|
ipAddress,
|
|
userAgent,
|
|
success: false,
|
|
errorMessage: message,
|
|
});
|
|
|
|
res.status(400).json({ error: message });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ai/proposals/{id}/reject:
|
|
* post:
|
|
* summary: Reject a pending proposal
|
|
* tags:
|
|
* - AI
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Proposal rejected.
|
|
* 404:
|
|
* description: Proposal not found.
|
|
*/
|
|
router.post(
|
|
"/proposals/:id/reject",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("ai.use"),
|
|
requireDataAccess,
|
|
aiGate,
|
|
async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId as string;
|
|
const id = parseId(req.params.id);
|
|
if (!id) return res.status(400).json({ error: "Invalid proposal id" });
|
|
|
|
try {
|
|
const updated = await createCurrentAiRepository().setProposalStatus(
|
|
id,
|
|
userId,
|
|
"rejected",
|
|
);
|
|
if (!updated) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Proposal not found or already resolved" });
|
|
}
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "ai_proposal_rejected",
|
|
resourceType: "ai_proposal",
|
|
resourceId: String(id),
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to reject AI proposal", err, {
|
|
operation: "ai_proposal_reject_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to reject the proposal" });
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|