7
This commit is contained in:
parent
eb455b41dd
commit
83cdb092cd
@ -36,6 +36,7 @@
|
|||||||
"sequelize": "6.35.2",
|
"sequelize": "6.35.2",
|
||||||
"sequelize-json-schema": "^2.1.1",
|
"sequelize-json-schema": "^2.1.1",
|
||||||
"sqlite": "4.0.15",
|
"sqlite": "4.0.15",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tedious": "^18.2.4"
|
"tedious": "^18.2.4"
|
||||||
|
|||||||
183
backend/src/db/migrations/20260515000000-create-subscriptions.js
Normal file
183
backend/src/db/migrations/20260515000000-create-subscriptions.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'subscription_plans',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
price_monthly: {
|
||||||
|
type: Sequelize.DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'usd',
|
||||||
|
},
|
||||||
|
stripe_product_id: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
stripe_price_id: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
message_limit: {
|
||||||
|
type: Sequelize.DataTypes.INTEGER,
|
||||||
|
},
|
||||||
|
agent_limit: {
|
||||||
|
type: Sequelize.DataTypes.INTEGER,
|
||||||
|
},
|
||||||
|
is_featured: {
|
||||||
|
type: Sequelize.DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
is_active: {
|
||||||
|
type: Sequelize.DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
features_json: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
updatedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
deletedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
importHash: {
|
||||||
|
type: Sequelize.DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'subscriptions',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
planId: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'subscription_plans',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.DataTypes.ENUM,
|
||||||
|
values: ['trialing', 'active', 'canceled', 'past_due'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active',
|
||||||
|
},
|
||||||
|
stripe_customer_id: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
stripe_subscription_id: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
current_period_start: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
},
|
||||||
|
current_period_end: {
|
||||||
|
type: Sequelize.DataTypes.DATE,
|
||||||
|
},
|
||||||
|
cancel_at_period_end: {
|
||||||
|
type: Sequelize.DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
updatedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
deletedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
importHash: {
|
||||||
|
type: Sequelize.DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryInterface.dropTable('subscriptions', { transaction });
|
||||||
|
await queryInterface.dropTable('subscription_plans', { transaction });
|
||||||
|
|
||||||
|
if (queryInterface.sequelize.getDialect() === 'postgres') {
|
||||||
|
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_subscriptions_status";', {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'billing_settings',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
defaultValue: 'default',
|
||||||
|
},
|
||||||
|
stripe_secret_key: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
stripe_webhook_secret: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
updatedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
deletedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
importHash: {
|
||||||
|
type: Sequelize.DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryInterface.dropTable('billing_settings', { transaction });
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
46
backend/src/db/models/billing_settings.js
Normal file
46
backend/src/db/models/billing_settings.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
module.exports = function (sequelize, DataTypes) {
|
||||||
|
const billing_settings = sequelize.define(
|
||||||
|
'billing_settings',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
defaultValue: 'default',
|
||||||
|
},
|
||||||
|
stripe_secret_key: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
stripe_webhook_secret: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
importHash: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
billing_settings.associate = (db) => {
|
||||||
|
db.billing_settings.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.billing_settings.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return billing_settings;
|
||||||
|
};
|
||||||
88
backend/src/db/models/subscription_plans.js
Normal file
88
backend/src/db/models/subscription_plans.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
module.exports = function (sequelize, DataTypes) {
|
||||||
|
const subscription_plans = sequelize.define(
|
||||||
|
'subscription_plans',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
price_monthly: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'usd',
|
||||||
|
},
|
||||||
|
stripe_product_id: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
stripe_price_id: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
message_limit: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
},
|
||||||
|
agent_limit: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
},
|
||||||
|
is_featured: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
is_active: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
features_json: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
importHash: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
subscription_plans.associate = (db) => {
|
||||||
|
db.subscription_plans.hasMany(db.subscriptions, {
|
||||||
|
as: 'subscriptions_plan',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'planId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.subscription_plans.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.subscription_plans.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return subscription_plans;
|
||||||
|
};
|
||||||
73
backend/src/db/models/subscriptions.js
Normal file
73
backend/src/db/models/subscriptions.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
module.exports = function (sequelize, DataTypes) {
|
||||||
|
const subscriptions = sequelize.define(
|
||||||
|
'subscriptions',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM,
|
||||||
|
values: ['trialing', 'active', 'canceled', 'past_due'],
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active',
|
||||||
|
},
|
||||||
|
stripe_customer_id: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
stripe_subscription_id: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
current_period_start: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
|
current_period_end: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
|
cancel_at_period_end: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
importHash: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.associate = (db) => {
|
||||||
|
db.subscriptions.belongsTo(db.users, {
|
||||||
|
as: 'user',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'userId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.subscriptions.belongsTo(db.subscription_plans, {
|
||||||
|
as: 'plan',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'planId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.subscriptions.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.subscriptions.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return subscriptions;
|
||||||
|
};
|
||||||
@ -172,6 +172,14 @@ provider: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.users.hasMany(db.subscriptions, {
|
||||||
|
as: 'subscriptions_user',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'userId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//end loop
|
//end loop
|
||||||
@ -252,4 +260,3 @@ function trimStringFields(users) {
|
|||||||
|
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
backend/src/db/seeders/20260515010000-subscription-plans.js
Normal file
88
backend/src/db/seeders/20260515010000-subscription-plans.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
const db = require('../models');
|
||||||
|
|
||||||
|
const PLAN_DATA = [
|
||||||
|
{
|
||||||
|
name: 'Starter',
|
||||||
|
slug: 'starter',
|
||||||
|
description: 'For solo builders who want a clean AI workspace and the basics of billing.',
|
||||||
|
price_monthly: 19,
|
||||||
|
currency: 'usd',
|
||||||
|
stripe_product_id: 'prod_mock_starter',
|
||||||
|
stripe_price_id: 'price_mock_starter_monthly',
|
||||||
|
message_limit: 300,
|
||||||
|
agent_limit: 3,
|
||||||
|
is_featured: false,
|
||||||
|
is_active: true,
|
||||||
|
features_json: JSON.stringify([
|
||||||
|
'300 AI messages every month',
|
||||||
|
'3 custom agents',
|
||||||
|
'Conversation history and export',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pro',
|
||||||
|
slug: 'pro',
|
||||||
|
description: 'For power users who need more throughput, better limits, and a polished workspace.',
|
||||||
|
price_monthly: 49,
|
||||||
|
currency: 'usd',
|
||||||
|
stripe_product_id: 'prod_mock_pro',
|
||||||
|
stripe_price_id: 'price_mock_pro_monthly',
|
||||||
|
message_limit: 1500,
|
||||||
|
agent_limit: 10,
|
||||||
|
is_featured: true,
|
||||||
|
is_active: true,
|
||||||
|
features_json: JSON.stringify([
|
||||||
|
'1,500 AI messages every month',
|
||||||
|
'10 custom agents',
|
||||||
|
'Priority billing support',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Team',
|
||||||
|
slug: 'team',
|
||||||
|
description: 'For heavier shared use with room for more agents, more messages, and longer workflows.',
|
||||||
|
price_monthly: 149,
|
||||||
|
currency: 'usd',
|
||||||
|
stripe_product_id: 'prod_mock_team',
|
||||||
|
stripe_price_id: 'price_mock_team_monthly',
|
||||||
|
message_limit: 5000,
|
||||||
|
agent_limit: 30,
|
||||||
|
is_featured: false,
|
||||||
|
is_active: true,
|
||||||
|
features_json: JSON.stringify([
|
||||||
|
'5,000 AI messages every month',
|
||||||
|
'30 custom agents',
|
||||||
|
'Shared workspace billing controls',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up() {
|
||||||
|
const adminUser = await db.users.findOne({
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const plan of PLAN_DATA) {
|
||||||
|
const [record] = await db.subscription_plans.findOrCreate({
|
||||||
|
where: {
|
||||||
|
slug: plan.slug,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
...plan,
|
||||||
|
createdById: adminUser ? adminUser.id : null,
|
||||||
|
updatedById: adminUser ? adminUser.id : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await record.update({
|
||||||
|
...plan,
|
||||||
|
updatedById: adminUser ? adminUser.id : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.bulkDelete('subscription_plans', null, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -36,6 +36,8 @@ const attachmentsRoutes = require('./routes/attachments');
|
|||||||
|
|
||||||
const usage_eventsRoutes = require('./routes/usage_events');
|
const usage_eventsRoutes = require('./routes/usage_events');
|
||||||
const workspaceRoutes = require('./routes/workspace');
|
const workspaceRoutes = require('./routes/workspace');
|
||||||
|
const billingRoutes = require('./routes/billing');
|
||||||
|
const billingWebhookRoutes = require('./routes/billingWebhook');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
const getBaseUrl = (url) => {
|
||||||
@ -87,6 +89,7 @@ app.use('/api-docs', function (req, res, next) {
|
|||||||
app.use(cors({origin: true}));
|
app.use(cors({origin: true}));
|
||||||
require('./auth/auth');
|
require('./auth/auth');
|
||||||
|
|
||||||
|
app.use('/api/billing/webhook', bodyParser.raw({ type: 'application/json' }), billingWebhookRoutes);
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
@ -111,6 +114,7 @@ app.use('/api/attachments', passport.authenticate('jwt', {session: false}), atta
|
|||||||
|
|
||||||
app.use('/api/usage_events', passport.authenticate('jwt', {session: false}), usage_eventsRoutes);
|
app.use('/api/usage_events', passport.authenticate('jwt', {session: false}), usage_eventsRoutes);
|
||||||
app.use('/api/workspace', passport.authenticate('jwt', { session: false }), workspaceRoutes);
|
app.use('/api/workspace', passport.authenticate('jwt', { session: false }), workspaceRoutes);
|
||||||
|
app.use('/api/billing', passport.authenticate('jwt', { session: false }), billingRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
|
|||||||
73
backend/src/routes/billing.js
Normal file
73
backend/src/routes/billing.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const BillingService = require('../services/billing');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/plans',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const plans = await BillingService.listPlans();
|
||||||
|
res.status(200).send({ plans });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/current',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const subscription = await BillingService.getCurrentSubscription(req.currentUser);
|
||||||
|
res.status(200).send({ subscription });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/stripe-settings',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const settings = await BillingService.getStripeSettings(req.currentUser, req.get('host'));
|
||||||
|
res.status(200).send({ settings });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/stripe-settings',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const settings = await BillingService.updateStripeSettings(
|
||||||
|
req.body.data,
|
||||||
|
req.currentUser,
|
||||||
|
req.get('host'),
|
||||||
|
);
|
||||||
|
res.status(200).send({ settings });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/subscribe',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const checkout = await BillingService.subscribe(req.body.planId, req.currentUser);
|
||||||
|
res.status(200).send(checkout);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/checkout-session',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const subscription = await BillingService.confirmCheckoutSession(
|
||||||
|
req.query.sessionId,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send({ subscription });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/cancel',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const subscription = await BillingService.cancel(req.currentUser);
|
||||||
|
res.status(200).send({ subscription });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
20
backend/src/routes/billingWebhook.js
Normal file
20
backend/src/routes/billingWebhook.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const BillingService = require('../services/billing');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const result = await BillingService.handleWebhook(
|
||||||
|
req.body,
|
||||||
|
req.headers['stripe-signature'],
|
||||||
|
);
|
||||||
|
res.status(200).send(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
857
backend/src/services/billing.js
Normal file
857
backend/src/services/billing.js
Normal file
@ -0,0 +1,857 @@
|
|||||||
|
const Stripe = require('stripe');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const config = require('../config');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = ['trialing', 'active', 'past_due'];
|
||||||
|
const Op = db.Sequelize.Op;
|
||||||
|
const STRIPE_WEBHOOK_EVENTS = [
|
||||||
|
'checkout.session.completed',
|
||||||
|
'customer.subscription.created',
|
||||||
|
'customer.subscription.updated',
|
||||||
|
'customer.subscription.deleted',
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseFeatures(featuresJson) {
|
||||||
|
if (!featuresJson) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(featuresJson);
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpError(message, code) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length <= 8) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, 4)}••••${value.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findStripeSettingsRecord() {
|
||||||
|
return db.billing_settings.findOne({
|
||||||
|
where: {
|
||||||
|
key: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateStripeSettingsRecord(currentUserId) {
|
||||||
|
const existingRecord = await findStripeSettingsRecord();
|
||||||
|
|
||||||
|
if (existingRecord) {
|
||||||
|
return existingRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.billing_settings.create({
|
||||||
|
createdById: currentUserId || null,
|
||||||
|
key: 'default',
|
||||||
|
updatedById: currentUserId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStripeSecretKeyValue() {
|
||||||
|
const settings = await findStripeSettingsRecord();
|
||||||
|
|
||||||
|
if (settings && settings.stripe_secret_key) {
|
||||||
|
return settings.stripe_secret_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.STRIPE_SECRET_KEY || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStripeWebhookSecretValue() {
|
||||||
|
const settings = await findStripeSettingsRecord();
|
||||||
|
|
||||||
|
if (settings && settings.stripe_webhook_secret) {
|
||||||
|
return settings.stripe_webhook_secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.STRIPE_WEBHOOK_SECRET || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStripeClient() {
|
||||||
|
const secretKey = await getStripeSecretKeyValue();
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
throw createHttpError('Stripe is not configured. Set STRIPE_SECRET_KEY.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Stripe(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrontendBaseUrl() {
|
||||||
|
if (process.env.FRONTEND_APP_URL) {
|
||||||
|
return process.env.FRONTEND_APP_URL.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_PUBLIC_BACK_API) {
|
||||||
|
return process.env.NEXT_PUBLIC_BACK_API.replace(/\/api\/?$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.FULL_DOMAIN) {
|
||||||
|
return `https://${process.env.FULL_DOMAIN.replace(/\/$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.HOST_FQDN) {
|
||||||
|
return `https://${process.env.HOST_FQDN.replace(/\/$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage') {
|
||||||
|
throw createHttpError(
|
||||||
|
'Stripe checkout is missing FRONTEND_APP_URL or NEXT_PUBLIC_BACK_API.',
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'http://localhost:3000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStripeWebhookUrl() {
|
||||||
|
return `${getFrontendBaseUrl()}/api/billing/webhook`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMockStripeId(value) {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value).includes('_mock_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStripeSubscriptionStatus(status) {
|
||||||
|
if (status === 'trialing') {
|
||||||
|
return 'trialing';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'active') {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'canceled') {
|
||||||
|
return 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'past_due') {
|
||||||
|
return 'past_due';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unpaid') {
|
||||||
|
return 'past_due';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'incomplete') {
|
||||||
|
return 'past_due';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'incomplete_expired') {
|
||||||
|
return 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'paused') {
|
||||||
|
return 'past_due';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'past_due';
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializePlan(plan) {
|
||||||
|
if (!plan) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
slug: plan.slug,
|
||||||
|
description: plan.description,
|
||||||
|
priceMonthly: plan.price_monthly,
|
||||||
|
currency: plan.currency,
|
||||||
|
stripeProductId: plan.stripe_product_id,
|
||||||
|
stripePriceId: plan.stripe_price_id,
|
||||||
|
messageLimit: plan.message_limit,
|
||||||
|
agentLimit: plan.agent_limit,
|
||||||
|
isFeatured: plan.is_featured,
|
||||||
|
isActive: plan.is_active,
|
||||||
|
features: parseFeatures(plan.features_json),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSubscription(subscription) {
|
||||||
|
if (!subscription) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: subscription.id,
|
||||||
|
status: subscription.status,
|
||||||
|
stripeCustomerId: subscription.stripe_customer_id,
|
||||||
|
stripeSubscriptionId: subscription.stripe_subscription_id,
|
||||||
|
currentPeriodStart: subscription.current_period_start,
|
||||||
|
currentPeriodEnd: subscription.current_period_end,
|
||||||
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||||
|
plan: serializePlan(subscription.plan),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStripeWebhookUrlForDisplay(requestHost) {
|
||||||
|
if (requestHost) {
|
||||||
|
const normalizedHost = String(requestHost)
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
|
||||||
|
return `https://${normalizedHost}/api/billing/webhook`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return getStripeWebhookUrl();
|
||||||
|
} catch (error) {
|
||||||
|
return '/api/billing/webhook';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeStripeSettings(settings, plans, requestHost) {
|
||||||
|
const savedSecretKey = settings ? settings.stripe_secret_key || '' : '';
|
||||||
|
const savedWebhookSecret = settings ? settings.stripe_webhook_secret || '' : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
plans: plans.map(serializePlan),
|
||||||
|
stripeSecretKey: savedSecretKey,
|
||||||
|
stripeSecretKeyPreview: maskSecret(savedSecretKey || process.env.STRIPE_SECRET_KEY || ''),
|
||||||
|
stripeSecretKeySource: savedSecretKey ? 'saved' : process.env.STRIPE_SECRET_KEY ? 'env' : 'missing',
|
||||||
|
stripeWebhookSecret: savedWebhookSecret,
|
||||||
|
stripeWebhookSecretPreview: maskSecret(savedWebhookSecret || process.env.STRIPE_WEBHOOK_SECRET || ''),
|
||||||
|
stripeWebhookSecretSource: savedWebhookSecret ? 'saved' : process.env.STRIPE_WEBHOOK_SECRET ? 'env' : 'missing',
|
||||||
|
webhookEndpoint: getStripeWebhookUrlForDisplay(requestHost),
|
||||||
|
webhookEvents: STRIPE_WEBHOOK_EVENTS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStripePriceIdFromSubscription(stripeSubscription) {
|
||||||
|
if (!stripeSubscription || !stripeSubscription.items || !Array.isArray(stripeSubscription.items.data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of stripeSubscription.items.data) {
|
||||||
|
if (item && item.price && item.price.id) {
|
||||||
|
return item.price.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerEmail(checkoutSession) {
|
||||||
|
if (checkoutSession.customer_details && checkoutSession.customer_details.email) {
|
||||||
|
return checkoutSession.customer_details.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkoutSession.customer_email) {
|
||||||
|
return checkoutSession.customer_email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = class BillingService {
|
||||||
|
static ensureCurrentUser(currentUser) {
|
||||||
|
if (!currentUser || !currentUser.id) {
|
||||||
|
throw new ForbiddenError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ensureAdministrator(currentUser) {
|
||||||
|
this.ensureCurrentUser(currentUser);
|
||||||
|
|
||||||
|
if (currentUser.app_role?.name !== config.roles.admin) {
|
||||||
|
throw new ForbiddenError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listPlans() {
|
||||||
|
const plans = await db.subscription_plans.findAll({
|
||||||
|
where: {
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
order: [
|
||||||
|
['price_monthly', 'ASC'],
|
||||||
|
['createdAt', 'ASC'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return plans.map(serializePlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCurrentSubscription(currentUser) {
|
||||||
|
this.ensureCurrentUser(currentUser);
|
||||||
|
|
||||||
|
const subscription = await this.findVisibleSubscription(currentUser.id);
|
||||||
|
return serializeSubscription(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStripeSettings(currentUser, requestHost) {
|
||||||
|
this.ensureAdministrator(currentUser);
|
||||||
|
|
||||||
|
const settings = await findOrCreateStripeSettingsRecord(currentUser.id);
|
||||||
|
const plans = await db.subscription_plans.findAll({
|
||||||
|
order: [
|
||||||
|
['price_monthly', 'ASC'],
|
||||||
|
['createdAt', 'ASC'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeStripeSettings(settings, plans, requestHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateStripeSettings(data, currentUser, requestHost) {
|
||||||
|
this.ensureAdministrator(currentUser);
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
throw createHttpError('Stripe settings payload is required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await findOrCreateStripeSettingsRecord(currentUser.id);
|
||||||
|
|
||||||
|
if (typeof data.stripeSecretKey === 'string') {
|
||||||
|
settings.stripe_secret_key = data.stripeSecretKey.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.stripeWebhookSecret === 'string') {
|
||||||
|
settings.stripe_webhook_secret = data.stripeWebhookSecret.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.updatedById = currentUser.id;
|
||||||
|
|
||||||
|
if (!settings.createdById) {
|
||||||
|
settings.createdById = currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
if (Array.isArray(data.plans)) {
|
||||||
|
for (const planData of data.plans) {
|
||||||
|
if (!planData || !planData.id) {
|
||||||
|
throw createHttpError('Each Stripe plan mapping must include an id.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await db.subscription_plans.findOne({
|
||||||
|
where: {
|
||||||
|
id: planData.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
throw createHttpError(`Subscription plan ${planData.id} was not found.`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof planData.stripeProductId === 'string') {
|
||||||
|
plan.stripe_product_id = planData.stripeProductId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof planData.stripePriceId === 'string') {
|
||||||
|
plan.stripe_price_id = planData.stripePriceId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.updatedById = currentUser.id;
|
||||||
|
await plan.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getStripeSettings(currentUser, requestHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async subscribe(planId, currentUser) {
|
||||||
|
this.ensureCurrentUser(currentUser);
|
||||||
|
|
||||||
|
if (!planId) {
|
||||||
|
throw createHttpError('Subscription plan is required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await db.subscription_plans.findOne({
|
||||||
|
where: {
|
||||||
|
id: planId,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
throw createHttpError('Subscription plan not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plan.stripe_price_id) {
|
||||||
|
throw createHttpError('This plan does not have a Stripe price yet.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMockStripeId(plan.stripe_price_id)) {
|
||||||
|
throw createHttpError(
|
||||||
|
'This plan is still using a mock Stripe price. Replace it with a real Stripe price ID first.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = await getStripeClient();
|
||||||
|
const customerId = await this.findOrCreateStripeCustomer(currentUser);
|
||||||
|
const frontendBaseUrl = getFrontendBaseUrl();
|
||||||
|
const successUrl = `${frontendBaseUrl}/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}`;
|
||||||
|
const cancelUrl = `${frontendBaseUrl}/billing?checkout=canceled`;
|
||||||
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
cancel_url: cancelUrl,
|
||||||
|
client_reference_id: currentUser.id,
|
||||||
|
customer: customerId,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: plan.stripe_price_id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
planId: plan.id,
|
||||||
|
userId: currentUser.id,
|
||||||
|
},
|
||||||
|
mode: 'subscription',
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
planId: plan.id,
|
||||||
|
userId: currentUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success_url: successUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkoutSession.url) {
|
||||||
|
throw createHttpError('Stripe did not return a checkout URL.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkoutSessionId: checkoutSession.id,
|
||||||
|
checkoutUrl: checkoutSession.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async confirmCheckoutSession(sessionId, currentUser) {
|
||||||
|
this.ensureCurrentUser(currentUser);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
throw createHttpError('Checkout session ID is required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = await getStripeClient();
|
||||||
|
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
const sessionUserId = checkoutSession.metadata ? checkoutSession.metadata.userId : null;
|
||||||
|
const sessionEmail = getCustomerEmail(checkoutSession);
|
||||||
|
|
||||||
|
if (
|
||||||
|
checkoutSession.client_reference_id !== currentUser.id &&
|
||||||
|
sessionUserId !== currentUser.id &&
|
||||||
|
sessionEmail !== currentUser.email
|
||||||
|
) {
|
||||||
|
throw new ForbiddenError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkoutSession.mode !== 'subscription') {
|
||||||
|
throw createHttpError('This checkout session does not contain a subscription.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkoutSession.subscription) {
|
||||||
|
return serializeSubscription(await this.findVisibleSubscription(currentUser.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeSubscription = await stripe.subscriptions.retrieve(checkoutSession.subscription);
|
||||||
|
const subscription = await this.syncStripeSubscription(stripeSubscription);
|
||||||
|
|
||||||
|
return serializeSubscription(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async cancel(currentUser) {
|
||||||
|
this.ensureCurrentUser(currentUser);
|
||||||
|
|
||||||
|
const currentSubscription = await this.findActiveSubscription(currentUser.id);
|
||||||
|
|
||||||
|
if (!currentSubscription) {
|
||||||
|
throw createHttpError('No active subscription to cancel.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!currentSubscription.stripe_subscription_id ||
|
||||||
|
isMockStripeId(currentSubscription.stripe_subscription_id)
|
||||||
|
) {
|
||||||
|
currentSubscription.status = 'canceled';
|
||||||
|
currentSubscription.cancel_at_period_end = false;
|
||||||
|
currentSubscription.current_period_end = new Date();
|
||||||
|
currentSubscription.updatedById = currentUser.id;
|
||||||
|
await currentSubscription.save();
|
||||||
|
|
||||||
|
const subscription = await this.findVisibleSubscription(currentUser.id);
|
||||||
|
return serializeSubscription(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = await getStripeClient();
|
||||||
|
const stripeSubscription = await stripe.subscriptions.update(
|
||||||
|
currentSubscription.stripe_subscription_id,
|
||||||
|
{
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const subscription = await this.syncStripeSubscription(stripeSubscription);
|
||||||
|
|
||||||
|
return serializeSubscription(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async handleWebhook(rawBody, signature) {
|
||||||
|
const webhookSecret = await getStripeWebhookSecretValue();
|
||||||
|
|
||||||
|
if (!webhookSecret) {
|
||||||
|
throw createHttpError('Stripe webhook is not configured. Set STRIPE_WEBHOOK_SECRET.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
throw createHttpError('Stripe-Signature header is required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = await getStripeClient();
|
||||||
|
const event = stripe.webhooks.constructEvent(
|
||||||
|
rawBody,
|
||||||
|
signature,
|
||||||
|
webhookSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
const checkoutSession = event.data.object;
|
||||||
|
|
||||||
|
if (checkoutSession.mode === 'subscription' && checkoutSession.subscription) {
|
||||||
|
const stripeSubscription = await stripe.subscriptions.retrieve(checkoutSession.subscription);
|
||||||
|
await this.syncStripeSubscription(stripeSubscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === 'customer.subscription.created' ||
|
||||||
|
event.type === 'customer.subscription.updated' ||
|
||||||
|
event.type === 'customer.subscription.deleted'
|
||||||
|
) {
|
||||||
|
await this.syncStripeSubscription(event.data.object);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
received: true,
|
||||||
|
type: event.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async syncStripeSubscription(stripeSubscription) {
|
||||||
|
const userId = await this.resolveUserId(stripeSubscription);
|
||||||
|
const plan = await this.resolvePlan(stripeSubscription);
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let subscription = await this.findSubscriptionByStripeSubscriptionId(
|
||||||
|
stripeSubscription.id,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subscription && stripeSubscription.customer) {
|
||||||
|
subscription = await this.findSubscriptionByStripeCustomerId(
|
||||||
|
String(stripeSubscription.customer),
|
||||||
|
userId,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = db.subscriptions.build({
|
||||||
|
createdById: userId,
|
||||||
|
stripe_customer_id: stripeSubscription.customer ? String(stripeSubscription.customer) : null,
|
||||||
|
stripe_subscription_id: stripeSubscription.id,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.userId = userId;
|
||||||
|
subscription.planId = plan.id;
|
||||||
|
subscription.status = normalizeStripeSubscriptionStatus(stripeSubscription.status);
|
||||||
|
subscription.stripe_customer_id = stripeSubscription.customer
|
||||||
|
? String(stripeSubscription.customer)
|
||||||
|
: null;
|
||||||
|
subscription.stripe_subscription_id = stripeSubscription.id;
|
||||||
|
subscription.current_period_start = stripeSubscription.current_period_start
|
||||||
|
? new Date(stripeSubscription.current_period_start * 1000)
|
||||||
|
: null;
|
||||||
|
subscription.current_period_end = stripeSubscription.current_period_end
|
||||||
|
? new Date(stripeSubscription.current_period_end * 1000)
|
||||||
|
: null;
|
||||||
|
subscription.cancel_at_period_end = Boolean(stripeSubscription.cancel_at_period_end);
|
||||||
|
subscription.updatedById = userId;
|
||||||
|
await subscription.save({ transaction });
|
||||||
|
|
||||||
|
await db.subscriptions.update(
|
||||||
|
{
|
||||||
|
status: 'canceled',
|
||||||
|
updatedById: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Op.ne]: subscription.id,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
[Op.in]: ACTIVE_STATUSES,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleSubscription = await this.findSubscriptionById(subscription.id, transaction);
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
return visibleSubscription;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async resolveUserId(stripeSubscription) {
|
||||||
|
if (
|
||||||
|
stripeSubscription.metadata &&
|
||||||
|
stripeSubscription.metadata.userId &&
|
||||||
|
typeof stripeSubscription.metadata.userId === 'string'
|
||||||
|
) {
|
||||||
|
return stripeSubscription.metadata.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSubscription = await this.findSubscriptionByStripeSubscriptionId(
|
||||||
|
stripeSubscription.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingSubscription && existingSubscription.userId) {
|
||||||
|
return existingSubscription.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripeSubscription.customer) {
|
||||||
|
const existingCustomerSubscription = await this.findSubscriptionByStripeCustomerId(
|
||||||
|
String(stripeSubscription.customer),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCustomerSubscription && existingCustomerSubscription.userId) {
|
||||||
|
return existingCustomerSubscription.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createHttpError(
|
||||||
|
`Stripe subscription ${stripeSubscription.id} is missing a user mapping.`,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async resolvePlan(stripeSubscription) {
|
||||||
|
if (
|
||||||
|
stripeSubscription.metadata &&
|
||||||
|
stripeSubscription.metadata.planId &&
|
||||||
|
typeof stripeSubscription.metadata.planId === 'string'
|
||||||
|
) {
|
||||||
|
const planById = await db.subscription_plans.findOne({
|
||||||
|
where: {
|
||||||
|
id: stripeSubscription.metadata.planId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (planById) {
|
||||||
|
return planById;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripePriceId = getStripePriceIdFromSubscription(stripeSubscription);
|
||||||
|
|
||||||
|
if (stripePriceId) {
|
||||||
|
const planByPrice = await db.subscription_plans.findOne({
|
||||||
|
where: {
|
||||||
|
stripe_price_id: stripePriceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (planByPrice) {
|
||||||
|
return planByPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSubscription = await this.findSubscriptionByStripeSubscriptionId(
|
||||||
|
stripeSubscription.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingSubscription && existingSubscription.planId) {
|
||||||
|
const existingPlan = await db.subscription_plans.findOne({
|
||||||
|
where: {
|
||||||
|
id: existingSubscription.planId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingPlan) {
|
||||||
|
return existingPlan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createHttpError(
|
||||||
|
`Stripe subscription ${stripeSubscription.id} could not be matched to a plan.`,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findOrCreateStripeCustomer(currentUser) {
|
||||||
|
const existingSubscription = await db.subscriptions.findOne({
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
where: {
|
||||||
|
stripe_customer_id: {
|
||||||
|
[Op.ne]: null,
|
||||||
|
},
|
||||||
|
userId: currentUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingSubscription &&
|
||||||
|
existingSubscription.stripe_customer_id &&
|
||||||
|
!isMockStripeId(existingSubscription.stripe_customer_id)
|
||||||
|
) {
|
||||||
|
return existingSubscription.stripe_customer_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = await getStripeClient();
|
||||||
|
const fullName = [currentUser.firstName, currentUser.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email: currentUser.email || undefined,
|
||||||
|
metadata: {
|
||||||
|
userId: currentUser.id,
|
||||||
|
},
|
||||||
|
name: fullName || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return customer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findActiveSubscription(userId, transaction) {
|
||||||
|
return db.subscriptions.findOne({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
[Op.in]: ACTIVE_STATUSES,
|
||||||
|
},
|
||||||
|
stripe_subscription_id: {
|
||||||
|
[Op.notLike]: '%_mock_%',
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.subscription_plans,
|
||||||
|
as: 'plan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findVisibleSubscription(userId, transaction) {
|
||||||
|
const activeSubscription = await this.findActiveSubscription(userId, transaction);
|
||||||
|
|
||||||
|
if (activeSubscription) {
|
||||||
|
return activeSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.subscriptions.findOne({
|
||||||
|
where: {
|
||||||
|
stripe_subscription_id: {
|
||||||
|
[Op.notLike]: '%_mock_%',
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.subscription_plans,
|
||||||
|
as: 'plan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findSubscriptionById(id, transaction) {
|
||||||
|
return db.subscriptions.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.subscription_plans,
|
||||||
|
as: 'plan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findSubscriptionByStripeSubscriptionId(stripeSubscriptionId, transaction) {
|
||||||
|
if (!stripeSubscriptionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.subscriptions.findOne({
|
||||||
|
where: {
|
||||||
|
stripe_subscription_id: stripeSubscriptionId,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.subscription_plans,
|
||||||
|
as: 'plan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findSubscriptionByStripeCustomerId(stripeCustomerId, userId, transaction) {
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
stripe_customer_id: stripeCustomerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
where.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.subscriptions.findOne({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.subscription_plans,
|
||||||
|
as: 'plan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,5 +1,10 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const config = require('../config');
|
||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const { LocalAIApi } = require('../ai/LocalAIApi');
|
const { LocalAIApi } = require('../ai/LocalAIApi');
|
||||||
|
const AttachmentsDBApi = require('../db/api/attachments');
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
|
||||||
const { Op } = db.Sequelize;
|
const { Op } = db.Sequelize;
|
||||||
@ -8,6 +13,10 @@ const DEFAULT_CONVERSATION_TITLE = 'New conversation';
|
|||||||
const MAX_MESSAGE_LENGTH = 8000;
|
const MAX_MESSAGE_LENGTH = 8000;
|
||||||
const MAX_TITLE_LENGTH = 120;
|
const MAX_TITLE_LENGTH = 120;
|
||||||
const MAX_CONTEXT_MESSAGES = 12;
|
const MAX_CONTEXT_MESSAGES = 12;
|
||||||
|
const MAX_ATTACHMENTS = 5;
|
||||||
|
const MAX_ATTACHMENT_TEXT_LENGTH = 12000;
|
||||||
|
const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||||
|
const IMAGE_INPUT_MODEL = 'gpt-5.2';
|
||||||
|
|
||||||
function normalizeText(value) {
|
function normalizeText(value) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
@ -26,6 +35,189 @@ function cleanMarkdownPreview(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentText(value) {
|
||||||
|
const normalized = normalizeText(value);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length <= MAX_ATTACHMENT_TEXT_LENGTH) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized.slice(0, MAX_ATTACHMENT_TEXT_LENGTH)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentsInput(value) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.slice(0, MAX_ATTACHMENTS)
|
||||||
|
.map((item) => {
|
||||||
|
const kind = item?.kind === 'image' ? 'image' : 'file';
|
||||||
|
const filename = normalizeText(item?.filename);
|
||||||
|
const mimeType = normalizeText(item?.mime_type);
|
||||||
|
const fileRelation = Array.isArray(item?.file) ? item.file.slice(0, 1) : [];
|
||||||
|
const imageRelation = Array.isArray(item?.image) ? item.image.slice(0, 1) : [];
|
||||||
|
const relation = kind === 'image' ? imageRelation : fileRelation;
|
||||||
|
|
||||||
|
if (!filename || relation.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
filename,
|
||||||
|
mime_type: mimeType || null,
|
||||||
|
size_bytes: Number(item?.size_bytes || 0) || null,
|
||||||
|
storage_key: normalizeText(item?.storage_key) || relation[0]?.privateUrl || null,
|
||||||
|
notes: normalizeAttachmentText(item?.notes),
|
||||||
|
file: kind === 'file' ? relation : [],
|
||||||
|
image: kind === 'image' ? relation : [],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentTitleSeed(attachments) {
|
||||||
|
if (!attachments.length) {
|
||||||
|
return DEFAULT_CONVERSATION_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.length === 1) {
|
||||||
|
return `Review ${attachments[0].filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Review ${attachments.length} attached files`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentSummaryForAi(attachment) {
|
||||||
|
const lines = [];
|
||||||
|
const typeLabel = attachment.kind === 'image' ? 'image' : 'file';
|
||||||
|
const mimeType = attachment.mime_type ? ` (${attachment.mime_type})` : '';
|
||||||
|
|
||||||
|
lines.push(`Attached ${typeLabel}: ${attachment.filename}${mimeType}`);
|
||||||
|
|
||||||
|
if (attachment.notes) {
|
||||||
|
lines.push(`Begin attached content: ${attachment.filename}`);
|
||||||
|
lines.push(attachment.notes);
|
||||||
|
lines.push(`End attached content: ${attachment.filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachmentImageInputUrl(attachment) {
|
||||||
|
if (!attachment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedFile = attachment.kind === 'image'
|
||||||
|
? Array.isArray(attachment.image) ? attachment.image[0] : null
|
||||||
|
: Array.isArray(attachment.file) ? attachment.file[0] : null;
|
||||||
|
|
||||||
|
if (relatedFile?.privateUrl) {
|
||||||
|
const absolutePath = path.join(config.uploadDir, relatedFile.privateUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = await fs.promises.readFile(absolutePath);
|
||||||
|
|
||||||
|
if (fileBuffer.length <= MAX_INLINE_IMAGE_BYTES) {
|
||||||
|
const mimeType = normalizeText(attachment.mime_type) || 'application/octet-stream';
|
||||||
|
return `data:${mimeType};base64,${fileBuffer.toString('base64')}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[workspace] Failed to read image attachment from local storage.', {
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
privateUrl: relatedFile.privateUrl,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicUrl = normalizeText(relatedFile?.publicUrl);
|
||||||
|
if (!publicUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageContentForAi(message) {
|
||||||
|
const content = normalizeText(message.content_markdown || message.content);
|
||||||
|
const attachments = Array.isArray(message.attachments_message) ? message.attachments_message : [];
|
||||||
|
|
||||||
|
if (!attachments.length) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentSummary = attachments.map(buildAttachmentSummaryForAi).join('\n\n');
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return `User attached files without additional text.\n\n${attachmentSummary}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${content}\n\n${attachmentSummary}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUserMessageContentBlocksForAi(message) {
|
||||||
|
const blocks = [];
|
||||||
|
const content = normalizeText(message.content_markdown || message.content);
|
||||||
|
const attachments = Array.isArray(message.attachments_message) ? message.attachments_message : [];
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'input_text',
|
||||||
|
text: content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const summary = buildAttachmentSummaryForAi(attachment);
|
||||||
|
if (summary) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'input_text',
|
||||||
|
text: summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.kind !== 'image') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageInputUrl = await attachmentImageInputUrl(attachment);
|
||||||
|
if (!imageInputUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: 'input_image',
|
||||||
|
image_url: imageInputUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blocks.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageHasImageAttachments(message) {
|
||||||
|
const attachments = Array.isArray(message?.attachments_message) ? message.attachments_message : [];
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachment?.kind === 'image') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function buildVisibleAgentWhere(currentUser) {
|
function buildVisibleAgentWhere(currentUser) {
|
||||||
return {
|
return {
|
||||||
is_active: true,
|
is_active: true,
|
||||||
@ -94,7 +286,7 @@ function buildAssistantFailureMessage(errorMessage) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAiInput(agent, historyMessages) {
|
async function buildAiInput(agent, historyMessages) {
|
||||||
const input = [];
|
const input = [];
|
||||||
const systemPrompt = normalizeText(agent?.system_prompt);
|
const systemPrompt = normalizeText(agent?.system_prompt);
|
||||||
const agentDescription = normalizeText(agent?.description);
|
const agentDescription = normalizeText(agent?.description);
|
||||||
@ -116,7 +308,20 @@ function buildAiInput(agent, historyMessages) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = normalizeText(message.content_markdown || message.content);
|
if (message.role === 'user') {
|
||||||
|
const contentBlocks = await buildUserMessageContentBlocksForAi(message);
|
||||||
|
if (!contentBlocks) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.push({
|
||||||
|
role: message.role,
|
||||||
|
content: contentBlocks,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = buildMessageContentForAi(message);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -144,12 +349,29 @@ async function requestAssistantReply(conversationId, assistantMessageId, agent)
|
|||||||
[Op.in]: ['user', 'assistant', 'system'],
|
[Op.in]: ['user', 'assistant', 'system'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.attachments,
|
||||||
|
as: 'attachments_message',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'image',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
order: [['sequence', 'DESC']],
|
order: [['sequence', 'DESC']],
|
||||||
limit: MAX_CONTEXT_MESSAGES,
|
limit: MAX_CONTEXT_MESSAGES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderedMessages = [...historyMessages].reverse();
|
const orderedMessages = [...historyMessages].reverse();
|
||||||
const input = buildAiInput(agent, orderedMessages);
|
const input = await buildAiInput(agent, orderedMessages);
|
||||||
|
const hasImageInput = orderedMessages.some(messageHasImageAttachments);
|
||||||
|
|
||||||
if (input.length === 0) {
|
if (input.length === 0) {
|
||||||
throw new Error('AI input could not be built from the conversation history.');
|
throw new Error('AI input could not be built from the conversation history.');
|
||||||
@ -159,7 +381,9 @@ async function requestAssistantReply(conversationId, assistantMessageId, agent)
|
|||||||
input,
|
input,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (agent?.model) {
|
if (hasImageInput) {
|
||||||
|
payload.model = IMAGE_INPUT_MODEL;
|
||||||
|
} else if (agent?.model) {
|
||||||
payload.model = agent.model;
|
payload.model = agent.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,6 +435,22 @@ async function findLatestUserMessageBeforeSequence(conversationId, sequence, tra
|
|||||||
[Op.lt]: sequence,
|
[Op.lt]: sequence,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.attachments,
|
||||||
|
as: 'attachments_message',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'image',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
order: [['sequence', 'DESC']],
|
order: [['sequence', 'DESC']],
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
@ -417,6 +657,35 @@ function serializeAgent(agent) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeAttachment(attachment) {
|
||||||
|
if (!attachment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedFile = attachment.kind === 'image'
|
||||||
|
? Array.isArray(attachment.image) ? attachment.image[0] : null
|
||||||
|
: Array.isArray(attachment.file) ? attachment.file[0] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: attachment.id,
|
||||||
|
kind: attachment.kind,
|
||||||
|
filename: attachment.filename,
|
||||||
|
mime_type: attachment.mime_type,
|
||||||
|
size_bytes: attachment.size_bytes,
|
||||||
|
storage_key: attachment.storage_key,
|
||||||
|
notes: attachment.notes,
|
||||||
|
file: relatedFile
|
||||||
|
? {
|
||||||
|
id: relatedFile.id,
|
||||||
|
name: relatedFile.name,
|
||||||
|
sizeInBytes: relatedFile.sizeInBytes,
|
||||||
|
privateUrl: relatedFile.privateUrl,
|
||||||
|
publicUrl: relatedFile.publicUrl,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function serializeMessage(message) {
|
function serializeMessage(message) {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@ -437,6 +706,11 @@ function serializeMessage(message) {
|
|||||||
email: message.author_user.email,
|
email: message.author_user.email,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
attachments: Array.isArray(message.attachments_message)
|
||||||
|
? message.attachments_message
|
||||||
|
.map(serializeAttachment)
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -600,6 +874,20 @@ module.exports = class WorkspaceService {
|
|||||||
as: 'author_user',
|
as: 'author_user',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'email'],
|
attributes: ['id', 'firstName', 'lastName', 'email'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: db.attachments,
|
||||||
|
as: 'attachments_message',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'image',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -798,8 +1086,9 @@ module.exports = class WorkspaceService {
|
|||||||
|
|
||||||
static async sendMessage(id, data, currentUser) {
|
static async sendMessage(id, data, currentUser) {
|
||||||
const content = normalizeText(data?.content);
|
const content = normalizeText(data?.content);
|
||||||
|
const attachments = normalizeAttachmentsInput(data?.attachments);
|
||||||
|
|
||||||
if (!content) {
|
if (!content && !attachments.length) {
|
||||||
throw new ValidationError();
|
throw new ValidationError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -813,6 +1102,8 @@ module.exports = class WorkspaceService {
|
|||||||
let userMessageId = null;
|
let userMessageId = null;
|
||||||
let agentId = null;
|
let agentId = null;
|
||||||
let agentModel = null;
|
let agentModel = null;
|
||||||
|
let titleSeed = content || buildAttachmentTitleSeed(attachments);
|
||||||
|
let aiSourceContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conversation = await findOwnedConversation(id, currentUser, createTransaction);
|
const conversation = await findOwnedConversation(id, currentUser, createTransaction);
|
||||||
@ -855,8 +1146,8 @@ module.exports = class WorkspaceService {
|
|||||||
const userMessage = await db.messages.create(
|
const userMessage = await db.messages.create(
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content,
|
content: content || '',
|
||||||
content_markdown: content,
|
content_markdown: content || '',
|
||||||
delivery_status: 'completed',
|
delivery_status: 'completed',
|
||||||
sent_at: sentAt,
|
sent_at: sentAt,
|
||||||
completed_at: sentAt,
|
completed_at: sentAt,
|
||||||
@ -869,6 +1160,26 @@ module.exports = class WorkspaceService {
|
|||||||
{ transaction: createTransaction },
|
{ transaction: createTransaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
await AttachmentsDBApi.create(
|
||||||
|
{
|
||||||
|
kind: attachment.kind,
|
||||||
|
filename: attachment.filename,
|
||||||
|
mime_type: attachment.mime_type,
|
||||||
|
size_bytes: attachment.size_bytes,
|
||||||
|
storage_key: attachment.storage_key,
|
||||||
|
notes: attachment.notes,
|
||||||
|
message: userMessage.id,
|
||||||
|
file: attachment.file,
|
||||||
|
image: attachment.image,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currentUser,
|
||||||
|
transaction: createTransaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const assistantMessage = await db.messages.create(
|
const assistantMessage = await db.messages.create(
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@ -895,15 +1206,19 @@ module.exports = class WorkspaceService {
|
|||||||
{ transaction: createTransaction },
|
{ transaction: createTransaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
const inputTokens = estimateTokens(content);
|
aiSourceContent = buildMessageContentForAi({
|
||||||
|
content_markdown: content || '',
|
||||||
|
content: content || '',
|
||||||
|
attachments_message: attachments,
|
||||||
|
});
|
||||||
|
|
||||||
await createUsageEvent(
|
await createUsageEvent(
|
||||||
{
|
{
|
||||||
event_type: 'message_sent',
|
event_type: 'message_sent',
|
||||||
occurred_at: sentAt,
|
occurred_at: sentAt,
|
||||||
input_tokens: inputTokens,
|
input_tokens: estimateTokens(aiSourceContent),
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
total_tokens: inputTokens,
|
total_tokens: estimateTokens(aiSourceContent),
|
||||||
cost_usd: 0,
|
cost_usd: 0,
|
||||||
provider: 'local-ai',
|
provider: 'local-ai',
|
||||||
model: agent?.model || 'default-ai-model',
|
model: agent?.model || 'default-ai-model',
|
||||||
@ -934,10 +1249,10 @@ module.exports = class WorkspaceService {
|
|||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
conversationId,
|
conversationId,
|
||||||
currentUser,
|
currentUser,
|
||||||
sourceContent: content,
|
sourceContent: aiSourceContent,
|
||||||
agentId,
|
agentId,
|
||||||
agentModel,
|
agentModel,
|
||||||
titleSeed: content,
|
titleSeed,
|
||||||
metadataAction: 'initial',
|
metadataAction: 'initial',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -960,6 +1275,7 @@ module.exports = class WorkspaceService {
|
|||||||
let sourceContent = '';
|
let sourceContent = '';
|
||||||
let agentId = null;
|
let agentId = null;
|
||||||
let agentModel = null;
|
let agentModel = null;
|
||||||
|
let titleSeed = DEFAULT_CONVERSATION_TITLE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conversation = await findOwnedConversation(id, currentUser, transaction);
|
const conversation = await findOwnedConversation(id, currentUser, transaction);
|
||||||
@ -1019,9 +1335,10 @@ module.exports = class WorkspaceService {
|
|||||||
|
|
||||||
conversationId = conversation.id;
|
conversationId = conversation.id;
|
||||||
assistantMessageId = assistantMessage.id;
|
assistantMessageId = assistantMessage.id;
|
||||||
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
|
sourceContent = buildMessageContentForAi(sourceMessage);
|
||||||
agentId = agent?.id || null;
|
agentId = agent?.id || null;
|
||||||
agentModel = agent?.model || 'default-ai-model';
|
agentModel = agent?.model || 'default-ai-model';
|
||||||
|
titleSeed = sourceMessage.content_markdown || sourceMessage.content || buildAttachmentTitleSeed(sourceMessage.attachments_message || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -1034,7 +1351,7 @@ module.exports = class WorkspaceService {
|
|||||||
sourceContent,
|
sourceContent,
|
||||||
agentId,
|
agentId,
|
||||||
agentModel,
|
agentModel,
|
||||||
titleSeed: sourceContent,
|
titleSeed,
|
||||||
metadataAction: 'retry',
|
metadataAction: 'retry',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1057,6 +1374,7 @@ module.exports = class WorkspaceService {
|
|||||||
let sourceContent = '';
|
let sourceContent = '';
|
||||||
let agentId = null;
|
let agentId = null;
|
||||||
let agentModel = null;
|
let agentModel = null;
|
||||||
|
let titleSeed = DEFAULT_CONVERSATION_TITLE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conversation = await findOwnedConversation(id, currentUser, transaction);
|
const conversation = await findOwnedConversation(id, currentUser, transaction);
|
||||||
@ -1116,9 +1434,10 @@ module.exports = class WorkspaceService {
|
|||||||
|
|
||||||
conversationId = conversation.id;
|
conversationId = conversation.id;
|
||||||
assistantMessageId = assistantMessage.id;
|
assistantMessageId = assistantMessage.id;
|
||||||
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
|
sourceContent = buildMessageContentForAi(sourceMessage);
|
||||||
agentId = agent?.id || null;
|
agentId = agent?.id || null;
|
||||||
agentModel = agent?.model || 'default-ai-model';
|
agentModel = agent?.model || 'default-ai-model';
|
||||||
|
titleSeed = sourceMessage.content_markdown || sourceMessage.content || buildAttachmentTitleSeed(sourceMessage.attachments_message || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -1131,7 +1450,7 @@ module.exports = class WorkspaceService {
|
|||||||
sourceContent,
|
sourceContent,
|
||||||
agentId,
|
agentId,
|
||||||
agentModel,
|
agentModel,
|
||||||
titleSeed: sourceContent,
|
titleSeed,
|
||||||
metadataAction: 'regenerate',
|
metadataAction: 'regenerate',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1341
backend/yarn.lock
1341
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
240
frontend/src/components/AdminEntity/PageKit.tsx
Normal file
240
frontend/src/components/AdminEntity/PageKit.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import BaseIcon from '../BaseIcon';
|
||||||
|
|
||||||
|
export const actionButtonClassName =
|
||||||
|
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
|
||||||
|
|
||||||
|
export const inputClassName =
|
||||||
|
'w-full rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-[14px] text-slate-900 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-300';
|
||||||
|
|
||||||
|
export const textAreaClassName = `${inputClassName} min-h-[132px] resize-y leading-6`;
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return 'No date';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortDate(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return 'No activity yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRole(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatName(person: any) {
|
||||||
|
if (!person) {
|
||||||
|
return 'No user';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = [person.firstName, person.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
|
if (fullName) {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.email) {
|
||||||
|
return person.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.name) {
|
||||||
|
return person.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.title) {
|
||||||
|
return person.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unnamed record';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(value: any) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
|
||||||
|
if (!number) {
|
||||||
|
return '$0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `$${number.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokens(value: any) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
|
||||||
|
if (!number) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return number.toLocaleString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityIntro({
|
||||||
|
backHref,
|
||||||
|
backLabel,
|
||||||
|
description,
|
||||||
|
kicker,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
backHref: string;
|
||||||
|
backLabel: string;
|
||||||
|
description: string;
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<Link className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500" href={backHref}>
|
||||||
|
{backLabel}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">{kicker}</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">{title}</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntitySection({
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={icon} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">{title}</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityValueCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">{label}</p>
|
||||||
|
<div className="mt-2 text-[15px] leading-6 text-slate-900">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityLinkCard({
|
||||||
|
description,
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
description: string;
|
||||||
|
href?: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
const content = (
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3 transition-colors hover:border-slate-300">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={icon} size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">{label}</p>
|
||||||
|
<p className="mt-2 text-[15px] font-medium text-slate-900">{value}</p>
|
||||||
|
<p className="mt-1 text-[13px] leading-6 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!href) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Link href={href}>{content}</Link>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityAsideCard({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">{title}</p>
|
||||||
|
<div className="mt-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityEmptyState({
|
||||||
|
description,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[12px] border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">No data</p>
|
||||||
|
<h2 className="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-slate-900">{title}</h2>
|
||||||
|
<p className="mt-3 text-[14px] leading-6 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
frontend/src/components/Agents/AgentFormSections.tsx
Normal file
222
frontend/src/components/Agents/AgentFormSections.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Field } from 'formik';
|
||||||
|
import { mdiRobotOutline, mdiTextBoxOutline, mdiTuneVariant } from '@mdi/js';
|
||||||
|
|
||||||
|
import BaseIcon from '../BaseIcon';
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'w-full rounded-[10px] border border-slate-200 bg-white px-3.5 py-2.5 text-[14px] text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900';
|
||||||
|
|
||||||
|
const textareaClassName =
|
||||||
|
'w-full rounded-[10px] border border-slate-200 bg-white px-3.5 py-3 text-[14px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900';
|
||||||
|
|
||||||
|
const labelClassName = 'mb-2 block text-[12px] font-medium text-slate-600';
|
||||||
|
|
||||||
|
function ToggleCard({
|
||||||
|
help,
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
onToggle,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
help: string;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
onToggle: (name: string, value: boolean) => void;
|
||||||
|
value: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-start justify-between gap-4 rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-left"
|
||||||
|
onClick={() => onToggle(name, !value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] font-medium text-slate-900">{label}</p>
|
||||||
|
<p className="mt-1 text-[13px] leading-6 text-slate-500">{help}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`relative mt-0.5 inline-flex h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||||
|
value ? 'bg-slate-900' : 'bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||||||
|
value ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setFieldValue: (field: string, value: any) => void;
|
||||||
|
values: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentFormSections({ setFieldValue, values }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiRobotOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">Identity</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Give the agent a name, a model, and a short description people can recognize quickly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName} htmlFor="name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Code Helper"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName} htmlFor="model">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id="model"
|
||||||
|
name="model"
|
||||||
|
placeholder="gpt-5.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className={labelClassName} htmlFor="description">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
as="textarea"
|
||||||
|
className={`${textareaClassName} min-h-[120px]`}
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Helpful assistant for planning, drafting, coding, or review."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiTextBoxOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">Prompting</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Define the standing instructions this agent should follow in every conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName} htmlFor="system_prompt">
|
||||||
|
System prompt
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
as="textarea"
|
||||||
|
className={`${textareaClassName} min-h-[240px]`}
|
||||||
|
id="system_prompt"
|
||||||
|
name="system_prompt"
|
||||||
|
placeholder="You are a concise engineering assistant. Explain tradeoffs clearly, propose the simplest implementation first, and never hide failures."
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-[12px] leading-5 text-slate-400">
|
||||||
|
Keep it direct. This is the instruction set the model receives before the user message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiTuneVariant} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">Behavior</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Tune response shape, token budget, and any structured metadata you want to keep with the agent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName} htmlFor="temperature">
|
||||||
|
Temperature
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id="temperature"
|
||||||
|
name="temperature"
|
||||||
|
placeholder="0.2"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-[12px] leading-5 text-slate-400">
|
||||||
|
Lower is more deterministic. Higher is more exploratory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName} htmlFor="max_output_tokens">
|
||||||
|
Max output tokens
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id="max_output_tokens"
|
||||||
|
name="max_output_tokens"
|
||||||
|
placeholder="1200"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-[12px] leading-5 text-slate-400">
|
||||||
|
Leave blank if you want the model default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className={labelClassName} htmlFor="metadata_json">
|
||||||
|
Metadata JSON
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
as="textarea"
|
||||||
|
className={`${textareaClassName} min-h-[140px] font-mono text-[13px]`}
|
||||||
|
id="metadata_json"
|
||||||
|
name="metadata_json"
|
||||||
|
placeholder='{"surface":"workspace","role":"engineer"}'
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-[12px] leading-5 text-slate-400">
|
||||||
|
Optional. Use valid JSON if you want to tag the agent with structured metadata.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||||
|
<ToggleCard
|
||||||
|
help="Keep this available in the workspace for active use."
|
||||||
|
label="Active"
|
||||||
|
name="is_active"
|
||||||
|
onToggle={setFieldValue}
|
||||||
|
value={Boolean(values.is_active)}
|
||||||
|
/>
|
||||||
|
<ToggleCard
|
||||||
|
help="Mark this as one of the shared default presets."
|
||||||
|
label="Default"
|
||||||
|
name="is_default"
|
||||||
|
onToggle={setFieldValue}
|
||||||
|
value={Boolean(values.is_default)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImageField from '../ImageField';
|
import Link from 'next/link';
|
||||||
|
import { mdiRobotOutline, mdiTextBoxOutline } from '@mdi/js';
|
||||||
|
import BaseIcon from '../BaseIcon';
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
import ListActionsPopover from '../ListActionsPopover';
|
||||||
import { useAppSelector } from '../../stores/hooks';
|
import { useAppSelector } from '../../stores/hooks';
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import { Pagination } from '../Pagination';
|
import { Pagination } from '../Pagination';
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
import LoadingSpinner from '../LoadingSpinner';
|
||||||
import LoadingSpinner from "../LoadingSpinner";
|
import { hasPermission } from '../../helpers/userPermissions';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -20,6 +18,61 @@ type Props = {
|
|||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function truncateText(value: string, limit: number) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length <= limit) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, limit).trim()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemperature(value: any) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return 'Auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenLimit(value: any) {
|
||||||
|
if (!value) {
|
||||||
|
return 'Default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value} max tokens`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadataBadges(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
const badges = [];
|
||||||
|
|
||||||
|
if (parsed.surface) {
|
||||||
|
badges.push(String(parsed.surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.role) {
|
||||||
|
badges.push(String(parsed.role));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.style) {
|
||||||
|
badges.push(String(parsed.style));
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges.slice(0, 2);
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const CardAgents = ({
|
const CardAgents = ({
|
||||||
agents,
|
agents,
|
||||||
loading,
|
loading,
|
||||||
@ -28,172 +81,160 @@ const CardAgents = ({
|
|||||||
numPages,
|
numPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const asideScrollbarsStyle = useAppSelector(
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
(state) => state.style.asideScrollbarsStyle,
|
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_AGENTS');
|
||||||
);
|
const hasCreatePermission = hasPermission(currentUser, 'CREATE_AGENTS');
|
||||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
|
||||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_AGENTS')
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'p-4'}>
|
<div className="px-5 py-5">
|
||||||
{loading && <LoadingSpinner />}
|
{loading && <LoadingSpinner />}
|
||||||
<ul
|
<ul
|
||||||
role='list'
|
role="list"
|
||||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
|
||||||
>
|
>
|
||||||
{!loading && agents.map((item, index) => (
|
{!loading && agents.map((item) => (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
className="overflow-hidden rounded-[12px] border border-slate-200 bg-white shadow-[0_20px_50px_-42px_rgba(15,23,42,0.28)]"
|
||||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
<div className="flex items-start gap-4 border-b border-slate-200 px-5 py-5">
|
||||||
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
|
||||||
|
<BaseIcon path={mdiRobotOutline} size={20} />
|
||||||
<Link href={`/agents/agents-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
</div>
|
||||||
{item.name}
|
<div className="min-w-0 flex-1">
|
||||||
</Link>
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
<div className='ml-auto '>
|
href={`/agents/agents-view/?id=${item.id}`}
|
||||||
<ListActionsPopover
|
className="block truncate text-[17px] font-semibold tracking-[-0.03em] text-slate-900"
|
||||||
onDelete={onDelete}
|
>
|
||||||
itemId={item.id}
|
{item.name || 'Untitled agent'}
|
||||||
pathEdit={`/agents/agents-edit/?id=${item.id}`}
|
</Link>
|
||||||
pathView={`/agents/agents-view/?id=${item.id}`}
|
<p className="mt-1 text-[13px] text-slate-500">
|
||||||
|
{item.model || 'Default model'}
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
</p>
|
||||||
|
</div>
|
||||||
/>
|
<div className="shrink-0">
|
||||||
|
<ListActionsPopover
|
||||||
|
onDelete={onDelete}
|
||||||
|
itemId={item.id}
|
||||||
|
pathEdit={`/agents/agents-edit/?id=${item.id}`}
|
||||||
|
pathView={`/agents/agents-view/?id=${item.id}`}
|
||||||
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-[999px] px-2.5 py-1 text-[11px] font-medium ${
|
||||||
|
item.is_active
|
||||||
|
? 'bg-emerald-50 text-emerald-700'
|
||||||
|
: 'bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-[999px] bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-700">
|
||||||
|
{item.is_default ? 'Default' : 'Custom'}
|
||||||
|
</span>
|
||||||
|
{getMetadataBadges(item.metadata_json).map((badge) => (
|
||||||
|
<span
|
||||||
|
key={`${item.id}-${badge}`}
|
||||||
|
className="inline-flex items-center rounded-[999px] bg-blue-50 px-2.5 py-1 text-[11px] font-medium text-blue-700"
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
|
|
||||||
|
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] leading-6 text-slate-600">
|
||||||
|
{truncateText(
|
||||||
|
item.description || item.system_prompt || 'No description yet.',
|
||||||
|
180,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
{item.system_prompt && (
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
|
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
<dd className='flex items-start gap-x-2'>
|
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
<div className='font-medium line-clamp-4'>
|
<BaseIcon path={mdiTextBoxOutline} size={14} />
|
||||||
{ item.name }
|
System prompt
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
<p className="mt-2 text-[13px] leading-6 text-slate-600">
|
||||||
|
{truncateText(item.system_prompt, 220)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
Temperature
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
|
</p>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<p className="mt-2 text-[15px] font-medium text-slate-900">
|
||||||
<div className='font-medium line-clamp-4'>
|
{formatTemperature(item.temperature)}
|
||||||
{ item.description }
|
</p>
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Output
|
||||||
|
</p>
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<p className="mt-2 text-[15px] font-medium text-slate-900">
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Model</dt>
|
{formatTokenLimit(item.max_output_tokens)}
|
||||||
<dd className='flex items-start gap-x-2'>
|
</p>
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.model }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Link
|
||||||
|
href={`/agents/agents-view/?id=${item.id}`}
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
className="inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3 py-2 text-[13px] font-medium text-slate-700"
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Systemprompt</dt>
|
>
|
||||||
<dd className='flex items-start gap-x-2'>
|
Open
|
||||||
<div className='font-medium line-clamp-4'>
|
</Link>
|
||||||
{ item.system_prompt }
|
{hasUpdatePermission && (
|
||||||
</div>
|
<Link
|
||||||
</dd>
|
href={`/agents/agents-edit/?id=${item.id}`}
|
||||||
</div>
|
className="inline-flex items-center justify-center rounded-[8px] bg-slate-900 px-3 py-2 text-[13px] font-medium text-white"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
</div>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Temperature</dt>
|
</div>
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.temperature }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Maxoutputtokens</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.max_output_tokens }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Isdefault</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ dataFormatter.booleanFormatter(item.is_default) }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Isactive</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ dataFormatter.booleanFormatter(item.is_active) }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>MetadataJSON</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.metadata_json }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</dl>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{!loading && agents.length === 0 && (
|
{!loading && agents.length === 0 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<div className="col-span-full rounded-[12px] border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center">
|
||||||
<p className=''>No data to display</p>
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
No agents yet
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-slate-900">
|
||||||
|
Create the first assistant profile for this workspace.
|
||||||
|
</h3>
|
||||||
|
<p className="mx-auto mt-3 max-w-xl text-[14px] leading-6 text-slate-500">
|
||||||
|
Add a reusable agent preset with a model, prompt, and response settings so the
|
||||||
|
workspace can start conversations with consistent behavior.
|
||||||
|
</p>
|
||||||
|
{hasCreatePermission && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Link
|
||||||
|
href="/agents/agents-new"
|
||||||
|
className="inline-flex items-center justify-center rounded-[8px] bg-slate-900 px-4 py-2 text-sm font-medium text-white"
|
||||||
|
>
|
||||||
|
New agent
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
<div className="mt-6 flex items-center justify-center">
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
numPages={numPages}
|
numPages={numPages}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react'
|
import React, { useEffect, useState, useMemo } from 'react'
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import BaseButton from '../BaseButton'
|
import BaseButton from '../BaseButton'
|
||||||
import CardBoxModal from '../CardBoxModal'
|
import CardBoxModal from '../CardBoxModal'
|
||||||
import CardBox from "../CardBox";
|
import CardBox from "../CardBox";
|
||||||
@ -17,6 +17,7 @@ import _ from 'lodash';
|
|||||||
import dataFormatter from '../../helpers/dataFormatter'
|
import dataFormatter from '../../helpers/dataFormatter'
|
||||||
import {dataGridStyles} from "../../styles";
|
import {dataGridStyles} from "../../styles";
|
||||||
import DataGridLoadingOverlay from '../DataGridLoadingOverlay';
|
import DataGridLoadingOverlay from '../DataGridLoadingOverlay';
|
||||||
|
import CardAgents from './CardAgents';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -445,8 +446,18 @@ const TableSampleAgents = ({ filterItems, setFilterItems, filters, showGrid }) =
|
|||||||
<p>Are you sure you want to delete this item?</p>
|
<p>Are you sure you want to delete this item?</p>
|
||||||
</CardBoxModal>
|
</CardBoxModal>
|
||||||
|
|
||||||
|
{showGrid ? (
|
||||||
{dataGrid}
|
dataGrid
|
||||||
|
) : (
|
||||||
|
<CardAgents
|
||||||
|
agents={agents ?? []}
|
||||||
|
loading={isDataGridLoading}
|
||||||
|
onDelete={handleDeleteModalAction}
|
||||||
|
currentPage={currentPage}
|
||||||
|
numPages={numPages}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -461,7 +472,6 @@ const TableSampleAgents = ({ filterItems, setFilterItems, filters, showGrid }) =
|
|||||||
/>,
|
/>,
|
||||||
document.getElementById('delete-rows-button'),
|
document.getElementById('delete-rows-button'),
|
||||||
)}
|
)}
|
||||||
<ToastContainer />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,26 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import AsideMenuLayer from './AsideMenuLayer'
|
import AsideMenuLayer from './AsideMenuLayer'
|
||||||
|
import OverlayLayer from './OverlayLayer'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[]
|
||||||
|
isAsideMobileExpanded: boolean
|
||||||
|
onAsideMobileClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AsideMenu({
|
export default function AsideMenu({
|
||||||
|
isAsideMobileExpanded = false,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<AsideMenuLayer
|
<>
|
||||||
menu={props.menu}
|
<AsideMenuLayer
|
||||||
className="left-0"
|
menu={props.menu}
|
||||||
/>
|
className={`${isAsideMobileExpanded ? 'translate-x-0' : '-translate-x-full sm:translate-x-0'} left-0`}
|
||||||
|
onAsideMobileCloseClick={props.onAsideMobileClose}
|
||||||
|
/>
|
||||||
|
{isAsideMobileExpanded && <OverlayLayer zIndex="z-30" onClick={props.onAsideMobileClose} />}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { mdiClose } from '@mdi/js'
|
||||||
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
@ -7,17 +9,23 @@ import { useAppSelector } from '../stores/hooks'
|
|||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onAsideMobileCloseClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AsideMenuLayer({ menu, className = '' }: Props) {
|
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
||||||
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
|
||||||
|
const handleAsideMobileCloseClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
props.onAsideMobileCloseClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
id='asideMenu'
|
id='asideMenu'
|
||||||
className={`${className} fixed inset-y-0 z-40 flex h-screen w-64 overflow-hidden border-r border-slate-200 bg-white transition-position shadow-[0_24px_60px_-42px_rgba(15,23,42,0.28)] lg:shadow-none dark:border-dark-700 dark:bg-dark-900`}
|
className={`${className} fixed inset-y-0 z-40 flex h-screen w-64 transform overflow-hidden border-r border-slate-200 bg-white transition-transform duration-200 ease-out shadow-[0_24px_60px_-42px_rgba(15,23,42,0.28)] sm:shadow-none dark:border-dark-700 dark:bg-dark-900`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex flex-1 flex-col overflow-hidden bg-white dark:bg-dark-900"
|
className="flex flex-1 flex-col overflow-hidden bg-white dark:bg-dark-900"
|
||||||
@ -30,6 +38,13 @@ export default function AsideMenuLayer({ menu, className = '' }: Props) {
|
|||||||
AI Chat Workspace
|
AI Chat Workspace
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex rounded-[8px] border border-slate-200 bg-white p-2 text-slate-500 sm:hidden dark:border-dark-700 dark:bg-dark-900 dark:text-slate-300"
|
||||||
|
onClick={handleAsideMobileCloseClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiClose} size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
||||||
|
|||||||
122
frontend/src/components/Billing/PricingPlanCard.tsx
Normal file
122
frontend/src/components/Billing/PricingPlanCard.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { BillingPlan } from '../../lib/billingPlans';
|
||||||
|
import { formatBillingLimit, formatBillingPrice } from '../../lib/billingPlans';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
plan: BillingPlan;
|
||||||
|
current?: boolean;
|
||||||
|
actionDisabled?: boolean;
|
||||||
|
actionHref?: string;
|
||||||
|
actionLabel: string;
|
||||||
|
actionLoading?: boolean;
|
||||||
|
caption?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryButtonClassName =
|
||||||
|
'inline-flex h-10 items-center justify-center rounded-[9px] bg-slate-900 px-4 text-sm font-medium text-white';
|
||||||
|
|
||||||
|
const secondaryButtonClassName =
|
||||||
|
'inline-flex h-10 items-center justify-center rounded-[9px] border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700';
|
||||||
|
|
||||||
|
export default function PricingPlanCard({
|
||||||
|
plan,
|
||||||
|
current = false,
|
||||||
|
actionDisabled = false,
|
||||||
|
actionHref,
|
||||||
|
actionLabel,
|
||||||
|
actionLoading = false,
|
||||||
|
caption,
|
||||||
|
onAction,
|
||||||
|
}: Props) {
|
||||||
|
const actionClassName = current || plan.isFeatured ? primaryButtonClassName : secondaryButtonClassName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`flex h-full flex-col rounded-[14px] border px-5 py-5 ${
|
||||||
|
plan.isFeatured
|
||||||
|
? 'border-slate-900 bg-slate-900 text-white shadow-[0_28px_90px_-48px_rgba(15,23,42,0.7)]'
|
||||||
|
: 'border-slate-200 bg-white text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={`text-[11px] font-semibold uppercase tracking-[0.28em] ${
|
||||||
|
plan.isFeatured ? 'text-slate-300' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.name}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-3 text-[2rem] font-semibold tracking-[-0.05em]">
|
||||||
|
{formatBillingPrice(plan.priceMonthly, plan.currency)}
|
||||||
|
</h3>
|
||||||
|
<p className={`mt-1 text-sm ${plan.isFeatured ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||||
|
per month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{current && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] ${
|
||||||
|
plan.isFeatured
|
||||||
|
? 'border border-white/20 bg-white/10 text-white/90'
|
||||||
|
: 'border border-slate-200 bg-slate-50 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!current && plan.isFeatured && (
|
||||||
|
<span className="rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/90">
|
||||||
|
Popular
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={`mt-5 text-sm leading-6 ${plan.isFeatured ? 'text-slate-200' : 'text-slate-500'}`}>
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`mt-5 grid gap-2 rounded-[12px] border px-4 py-4 text-sm ${
|
||||||
|
plan.isFeatured ? 'border-white/15 bg-white/5 text-slate-200' : 'border-slate-200 bg-slate-50 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>{formatBillingLimit(plan.messageLimit, 'messages / month')}</div>
|
||||||
|
<div>{formatBillingLimit(plan.agentLimit, 'custom agents')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={`mt-5 grid gap-3 text-sm leading-6 ${plan.isFeatured ? 'text-slate-200' : 'text-slate-600'}`}>
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li className="flex items-start gap-3" key={feature}>
|
||||||
|
<span className={`mt-1 h-1.5 w-1.5 rounded-full ${plan.isFeatured ? 'bg-white/80' : 'bg-slate-900'}`} />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{actionHref ? (
|
||||||
|
<Link className={actionClassName} href={actionHref}>
|
||||||
|
{actionLabel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`${actionClassName} ${actionDisabled ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||||
|
disabled={actionDisabled}
|
||||||
|
onClick={onAction}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Processing…' : actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caption && (
|
||||||
|
<p className={`mt-4 text-[12px] leading-5 ${plan.isFeatured ? 'text-slate-400' : 'text-slate-400'}`}>
|
||||||
|
{caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,150 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import useDevCompilationStatus from '../hooks/useDevCompilationStatus';
|
|
||||||
const DevModeBadge: React.FC = () => {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
|
||||||
const compilationStatus = useDevCompilationStatus();
|
|
||||||
|
|
||||||
const [badgeStyles, setBadgeStyles] = useState<React.CSSProperties>({
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: '20px',
|
|
||||||
left: '70px',
|
|
||||||
background: 'rgba(0, 0, 0, 0.85)',
|
|
||||||
color: 'white',
|
|
||||||
padding: '15px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontFamily: 'sans-serif',
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
textAlign: 'left',
|
|
||||||
zIndex: 2147483647,
|
|
||||||
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.3)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
transition: 'width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), padding 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease-in-out', // Improved transition for width
|
|
||||||
opacity: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
width: '340px',
|
|
||||||
maxWidth: '340px',
|
|
||||||
height: 'auto',
|
|
||||||
overflow: 'hidden',
|
|
||||||
cursor: 'pointer',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fullText = `🚧 Your app is running in development mode.
|
|
||||||
Current request is compiling and may take a few moments.
|
|
||||||
|
|
||||||
💡 Tip: Set up a stable environment to run your app in production mode—pages will load instantly without compilation delays.`;
|
|
||||||
|
|
||||||
const collapsedText = '🚧 DEV stage';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (compilationStatus === 'ready') {
|
|
||||||
setIsCollapsed(true);
|
|
||||||
} else {
|
|
||||||
setIsCollapsed(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
}, [compilationStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') {
|
|
||||||
setIsVisible(true);
|
|
||||||
|
|
||||||
setBadgeStyles(prev => ({
|
|
||||||
...prev,
|
|
||||||
opacity: 1,
|
|
||||||
width: '120px',
|
|
||||||
maxWidth: '120px',
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: '18px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
}));
|
|
||||||
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
setBadgeStyles(prev => ({ ...prev, opacity: 0 }));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVisible) return;
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
|
||||||
setBadgeStyles(prev => ({
|
|
||||||
...prev,
|
|
||||||
width: '140px',
|
|
||||||
maxWidth: '160px',
|
|
||||||
padding: '6px 20px',
|
|
||||||
borderRadius: '18px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: '12px',
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setBadgeStyles(prev => ({
|
|
||||||
...prev,
|
|
||||||
width: '340px',
|
|
||||||
maxWidth: '340px',
|
|
||||||
padding: '15px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
fontSize: '14px',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [isCollapsed, isVisible]);
|
|
||||||
|
|
||||||
const handleToggleCollapse = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsCollapsed(prev => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={badgeStyles} onClick={isCollapsed ? handleToggleCollapse : undefined}>
|
|
||||||
<button
|
|
||||||
onClick={handleToggleCollapse}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: isCollapsed ? '3px' : '5px',
|
|
||||||
right: isCollapsed ? '2px' : '5px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: isCollapsed ? '10px' : '18px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '2px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
lineHeight: '1',
|
|
||||||
width: '24px',
|
|
||||||
height: isCollapsed ? '24px' : '24px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
transition: 'background-color 0.2s ease, font-size 0.2s ease, width 0.2s ease, height 0.2s ease',
|
|
||||||
}}
|
|
||||||
aria-label={isCollapsed ? "Expand message" : "Collapse message"}
|
|
||||||
>
|
|
||||||
{isCollapsed ? '+' : '×'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div style={{ marginRight: '20px' }}>
|
|
||||||
{fullText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isCollapsed && (
|
|
||||||
<div style={{ marginRight: '10px' }}>
|
|
||||||
{collapsedText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DevModeBadge;
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Button from '@mui/material/Button';
|
|
||||||
import BaseIcon from './BaseIcon';
|
import BaseIcon from './BaseIcon';
|
||||||
import {
|
import {
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
@ -9,7 +8,6 @@ import {
|
|||||||
mdiTrashCan,
|
mdiTrashCan,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import Popover from '@mui/material/Popover';
|
import Popover from '@mui/material/Popover';
|
||||||
import { IconButton } from '@mui/material';
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -31,8 +29,8 @@ const ListActionsPopover = ({
|
|||||||
pathEdit,
|
pathEdit,
|
||||||
pathView,
|
pathView,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [anchorEl, setAnchorEl] = React.useState(null);
|
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
|
||||||
const handleClick = (event) => {
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
};
|
};
|
||||||
const linkView = pathView;
|
const linkView = pathView;
|
||||||
@ -46,20 +44,20 @@ const ListActionsPopover = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<button
|
||||||
aria-describedby={id}
|
aria-describedby={id}
|
||||||
|
className={`inline-flex h-9 w-9 items-center justify-center rounded-[8px] border border-transparent bg-white text-slate-500 transition-colors hover:border-slate-200 hover:text-slate-900 ${className || ''}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={`rounded-full ${className}`}
|
type="button"
|
||||||
size={'small'}
|
|
||||||
>
|
>
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
className={`text-black dark:text-white ${iconClassName}`}
|
className={`${iconClassName || ''}`}
|
||||||
w='w-10'
|
w='w-5'
|
||||||
h='h-10'
|
h='h-5'
|
||||||
size={24}
|
size={18}
|
||||||
path={mdiDotsVertical}
|
path={mdiDotsVertical}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</button>
|
||||||
<Popover
|
<Popover
|
||||||
id={id}
|
id={id}
|
||||||
open={open}
|
open={open}
|
||||||
@ -67,44 +65,48 @@ const ListActionsPopover = ({
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'bottom',
|
vertical: 'bottom',
|
||||||
horizontal: 'left',
|
horizontal: 'right',
|
||||||
}}
|
}}
|
||||||
transformOrigin={{
|
transformOrigin={{
|
||||||
vertical: 'top',
|
vertical: 'top',
|
||||||
horizontal: 'center',
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
className:
|
||||||
|
'mt-2 overflow-hidden rounded-[10px] border border-slate-200 bg-white shadow-[0_24px_50px_-36px_rgba(15,23,42,0.35)]',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={'flex flex-col'}>
|
<div className="flex min-w-[180px] flex-col p-1.5">
|
||||||
<Button
|
<Link
|
||||||
startIcon={<BaseIcon path={mdiEye} size={24} />}
|
className="inline-flex items-center gap-3 rounded-[8px] px-3 py-2 text-[14px] font-medium text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-900"
|
||||||
className='w-full MuiButton-colorInherit'
|
|
||||||
href={linkView}
|
href={linkView}
|
||||||
sx={{ justifyContent: "start" }}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
View
|
<BaseIcon className="text-slate-400" path={mdiEye} size={16} />
|
||||||
</Button>
|
<span>View</span>
|
||||||
|
</Link>
|
||||||
{hasUpdatePermission && (
|
{hasUpdatePermission && (
|
||||||
<Button
|
<Link
|
||||||
startIcon={<BaseIcon path={mdiPencilOutline} size={24} />}
|
className="inline-flex items-center gap-3 rounded-[8px] px-3 py-2 text-[14px] font-medium text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-900"
|
||||||
className='w-full MuiButton-colorInherit'
|
|
||||||
href={linkEdit}
|
href={linkEdit}
|
||||||
sx={{ justifyContent: "start" }}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
Edit
|
<BaseIcon className="text-slate-400" path={mdiPencilOutline} size={16} />
|
||||||
</Button>
|
<span>Edit</span>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
{hasUpdatePermission && (
|
{hasUpdatePermission && (
|
||||||
<Button
|
<button
|
||||||
startIcon={<BaseIcon path={mdiTrashCan} size={24} />}
|
className="inline-flex items-center gap-3 rounded-[8px] px-3 py-2 text-[14px] font-medium text-rose-600 transition-colors hover:bg-rose-50"
|
||||||
className='MuiButton-colorInherit'
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleClose();
|
handleClose();
|
||||||
onDelete(itemId);
|
onDelete(itemId);
|
||||||
}}
|
}}
|
||||||
sx={{ justifyContent: "start" }}
|
type="button"
|
||||||
>
|
>
|
||||||
Delete
|
<BaseIcon className="text-rose-500" path={mdiTrashCan} size={16} />
|
||||||
</Button>
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@ -5,11 +5,16 @@ import {
|
|||||||
mdiClose,
|
mdiClose,
|
||||||
mdiCogOutline,
|
mdiCogOutline,
|
||||||
mdiDeleteOutline,
|
mdiDeleteOutline,
|
||||||
|
mdiFileOutline,
|
||||||
|
mdiImageOutline,
|
||||||
mdiMenu,
|
mdiMenu,
|
||||||
|
mdiMicrophoneOutline,
|
||||||
mdiOpenInNew,
|
mdiOpenInNew,
|
||||||
mdiPencilOutline,
|
mdiPencilOutline,
|
||||||
|
mdiPaperclip,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiRefresh,
|
mdiRefresh,
|
||||||
|
mdiStopCircleOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@ -20,6 +25,7 @@ import BaseIcon from '../BaseIcon';
|
|||||||
import { ADMIN_ENTRY_PERMISSIONS, hasPermission } from '../../helpers/userPermissions';
|
import { ADMIN_ENTRY_PERMISSIONS, hasPermission } from '../../helpers/userPermissions';
|
||||||
import { useAppSelector } from '../../stores/hooks';
|
import { useAppSelector } from '../../stores/hooks';
|
||||||
import ChatMarkdown from './ChatMarkdown';
|
import ChatMarkdown from './ChatMarkdown';
|
||||||
|
import FileUploader from '../Uploaders/UploadService';
|
||||||
|
|
||||||
type AgentSummary = {
|
type AgentSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,6 +35,26 @@ type AgentSummary = {
|
|||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorkspaceAttachmentFile = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
sizeInBytes?: number;
|
||||||
|
privateUrl?: string;
|
||||||
|
publicUrl?: string;
|
||||||
|
new?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceAttachment = {
|
||||||
|
id: string;
|
||||||
|
kind: 'file' | 'image';
|
||||||
|
filename: string;
|
||||||
|
mime_type?: string | null;
|
||||||
|
size_bytes?: number | null;
|
||||||
|
storage_key?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
file?: WorkspaceAttachmentFile | null;
|
||||||
|
};
|
||||||
|
|
||||||
type WorkspaceMessage = {
|
type WorkspaceMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||||
@ -41,8 +67,53 @@ type WorkspaceMessage = {
|
|||||||
sequence?: number;
|
sequence?: number;
|
||||||
optimistic?: boolean;
|
optimistic?: boolean;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
|
attachments?: WorkspaceAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ComposerAttachment = {
|
||||||
|
id: string;
|
||||||
|
kind: 'file' | 'image';
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
storage_key: string;
|
||||||
|
notes?: string | null;
|
||||||
|
upload: WorkspaceAttachmentFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpeechRecognitionAlternativeLike = {
|
||||||
|
transcript: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpeechRecognitionResultLike = {
|
||||||
|
0: SpeechRecognitionAlternativeLike;
|
||||||
|
isFinal: boolean;
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpeechRecognitionEventLike = {
|
||||||
|
resultIndex: number;
|
||||||
|
results: ArrayLike<SpeechRecognitionResultLike>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpeechRecognitionLike = {
|
||||||
|
continuous: boolean;
|
||||||
|
interimResults: boolean;
|
||||||
|
lang: string;
|
||||||
|
onend: null | (() => void);
|
||||||
|
onerror: null | (() => void);
|
||||||
|
onresult: null | ((event: SpeechRecognitionEventLike) => void);
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition?: new () => SpeechRecognitionLike;
|
||||||
|
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ConversationSummary = {
|
type ConversationSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -137,6 +208,19 @@ const NOTES_AGENT_STARTER: AgentStarterConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ATTACHMENT_UPLOAD_PATH = 'messages/attachments';
|
||||||
|
const MAX_ATTACHMENTS = 5;
|
||||||
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||||
|
const TEXT_ATTACHMENT_EXTENSIONS = ['txt', 'md', 'markdown', 'json', 'csv', 'tsv', 'log', 'xml', 'yml', 'yaml'];
|
||||||
|
const TEXT_ATTACHMENT_MIME_PREFIXES = ['text/'];
|
||||||
|
const TEXT_ATTACHMENT_MIME_TYPES = [
|
||||||
|
'application/json',
|
||||||
|
'application/xml',
|
||||||
|
'application/javascript',
|
||||||
|
'application/x-yaml',
|
||||||
|
];
|
||||||
|
const MAX_ATTACHMENT_TEXT_LENGTH = 12000;
|
||||||
|
|
||||||
function getAgentStarterConfig(agent?: AgentSummary | null): AgentStarterConfig {
|
function getAgentStarterConfig(agent?: AgentSummary | null): AgentStarterConfig {
|
||||||
const haystack = `${agent?.name || ''} ${agent?.description || ''}`.toLowerCase();
|
const haystack = `${agent?.name || ''} ${agent?.description || ''}`.toLowerCase();
|
||||||
|
|
||||||
@ -217,6 +301,64 @@ const formatMessageTime = (value?: string) => {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFileExtension = (filename: string) => {
|
||||||
|
const parts = filename.toLowerCase().split('.');
|
||||||
|
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const canExtractTextFromAttachment = (file: File) => {
|
||||||
|
const extension = getFileExtension(file.name);
|
||||||
|
|
||||||
|
if (TEXT_ATTACHMENT_EXTENSIONS.includes(extension)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TEXT_ATTACHMENT_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TEXT_ATTACHMENT_MIME_TYPES.includes(file.type);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAttachmentText = async (file: File) => {
|
||||||
|
if (!canExtractTextFromAttachment(file)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (await file.text()).trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length <= MAX_ATTACHMENT_TEXT_LENGTH) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text.slice(0, MAX_ATTACHMENT_TEXT_LENGTH)}...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAttachmentSize = (value?: number | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 1024) {
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 1024 * 1024) {
|
||||||
|
return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
if (typeof error.response?.data === 'string') {
|
if (typeof error.response?.data === 'string') {
|
||||||
@ -281,6 +423,116 @@ function TypingIndicator() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttachmentChipProps = {
|
||||||
|
attachment: {
|
||||||
|
id: string;
|
||||||
|
kind: 'file' | 'image';
|
||||||
|
filename: string;
|
||||||
|
file?: WorkspaceAttachmentFile | null;
|
||||||
|
size_bytes?: number | null;
|
||||||
|
};
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
muted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AttachmentChip({ attachment, onRemove, muted = false }: AttachmentChipProps) {
|
||||||
|
const href = attachment.file?.publicUrl || '';
|
||||||
|
const icon = attachment.kind === 'image' ? mdiImageOutline : mdiFileOutline;
|
||||||
|
const isImagePreview = attachment.kind === 'image' && Boolean(href);
|
||||||
|
|
||||||
|
if (isImagePreview) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden rounded-[10px] border ${
|
||||||
|
muted
|
||||||
|
? 'border-white/15 bg-white/10 text-white'
|
||||||
|
: 'border-slate-200 bg-white text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
className="block"
|
||||||
|
href={href}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt={attachment.filename}
|
||||||
|
className="block h-28 w-36 object-cover"
|
||||||
|
src={href}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<div className="flex items-start justify-between gap-2 px-2.5 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={`mb-1 flex items-center gap-1.5 text-[10px] ${muted ? 'text-white/65' : 'text-slate-400'}`}>
|
||||||
|
<BaseIcon path={mdiImageOutline} size={13} />
|
||||||
|
<span>Image</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11px] font-medium">
|
||||||
|
{attachment.filename}
|
||||||
|
</div>
|
||||||
|
{attachment.size_bytes ? (
|
||||||
|
<div className={`mt-0.5 text-[10px] ${muted ? 'text-white/55' : 'text-slate-400'}`}>
|
||||||
|
{formatAttachmentSize(attachment.size_bytes)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
className={`inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] ${
|
||||||
|
muted
|
||||||
|
? 'text-white/60 hover:bg-white/10 hover:text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => onRemove(attachment.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiClose} size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex max-w-full items-center gap-2 rounded-[8px] border px-2.5 py-1.5 text-[11px] ${
|
||||||
|
muted
|
||||||
|
? 'border-slate-200 bg-white/80 text-slate-600'
|
||||||
|
: 'border-slate-200 bg-slate-50 text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BaseIcon path={icon} size={15} />
|
||||||
|
{href ? (
|
||||||
|
<a
|
||||||
|
className="max-w-[13rem] truncate font-medium hover:underline"
|
||||||
|
href={href}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{attachment.filename}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="max-w-[13rem] truncate font-medium">{attachment.filename}</span>
|
||||||
|
)}
|
||||||
|
{attachment.size_bytes ? (
|
||||||
|
<span className={muted ? 'text-slate-400' : 'text-slate-500'}>
|
||||||
|
{formatAttachmentSize(attachment.size_bytes)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center text-slate-400 hover:text-slate-700"
|
||||||
|
onClick={() => onRemove(attachment.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiClose} size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type MessageBubbleProps = {
|
type MessageBubbleProps = {
|
||||||
currentUserAvatarUrl: string;
|
currentUserAvatarUrl: string;
|
||||||
currentUserInitial: string;
|
currentUserInitial: string;
|
||||||
@ -355,6 +607,18 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Array.isArray(message.attachments) && message.attachments.length ? (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
|
{message.attachments.map((attachment) => (
|
||||||
|
<AttachmentChip
|
||||||
|
attachment={attachment}
|
||||||
|
key={attachment.id}
|
||||||
|
muted={isUser}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{message.pending ? (
|
{message.pending ? (
|
||||||
<TypingIndicator />
|
<TypingIndicator />
|
||||||
) : isStreaming ? (
|
) : isStreaming ? (
|
||||||
@ -438,6 +702,8 @@ function ConversationRow({ conversation, isActive, onSelect }: ConversationRowPr
|
|||||||
export default function WorkspaceShell() {
|
export default function WorkspaceShell() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const didBootstrapRef = useRef(false);
|
const didBootstrapRef = useRef(false);
|
||||||
@ -452,6 +718,9 @@ export default function WorkspaceShell() {
|
|||||||
const [loadingBootstrap, setLoadingBootstrap] = useState(true);
|
const [loadingBootstrap, setLoadingBootstrap] = useState(true);
|
||||||
const [loadingConversation, setLoadingConversation] = useState(false);
|
const [loadingConversation, setLoadingConversation] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [uploadingAttachment, setUploadingAttachment] = useState(false);
|
||||||
|
const [speechSupported, setSpeechSupported] = useState(false);
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
@ -461,6 +730,7 @@ export default function WorkspaceShell() {
|
|||||||
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null);
|
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null);
|
||||||
const [streamingText, setStreamingText] = useState('');
|
const [streamingText, setStreamingText] = useState('');
|
||||||
const [hasBootstrapped, setHasBootstrapped] = useState(false);
|
const [hasBootstrapped, setHasBootstrapped] = useState(false);
|
||||||
|
const [composerAttachments, setComposerAttachments] = useState<ComposerAttachment[]>([]);
|
||||||
|
|
||||||
const showSuccessToast = useCallback((message: string) => {
|
const showSuccessToast = useCallback((message: string) => {
|
||||||
toast(message, {
|
toast(message, {
|
||||||
@ -476,6 +746,23 @@ export default function WorkspaceShell() {
|
|||||||
const currentUserInitial = getUserInitial(currentUser);
|
const currentUserInitial = getUserInitial(currentUser);
|
||||||
const canAccessAdmin = hasPermission(currentUser, ADMIN_ENTRY_PERMISSIONS);
|
const canAccessAdmin = hasPermission(currentUser, ADMIN_ENTRY_PERMISSIONS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.SpeechRecognition || window.webkitSpeechRecognition) {
|
||||||
|
setSpeechSupported(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
recognitionRef.current.stop();
|
||||||
|
recognitionRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeConversations = useMemo(
|
const activeConversations = useMemo(
|
||||||
() => conversations.filter((conversation) => conversation.status !== 'archived'),
|
() => conversations.filter((conversation) => conversation.status !== 'archived'),
|
||||||
[conversations],
|
[conversations],
|
||||||
@ -488,6 +775,7 @@ export default function WorkspaceShell() {
|
|||||||
() => [...(activeConversation?.messages || []), ...optimisticMessages],
|
() => [...(activeConversation?.messages || []), ...optimisticMessages],
|
||||||
[activeConversation?.messages, optimisticMessages],
|
[activeConversation?.messages, optimisticMessages],
|
||||||
);
|
);
|
||||||
|
const canSubmitMessage = Boolean(composer.trim() || composerAttachments.length);
|
||||||
|
|
||||||
const upsertConversation = useCallback((conversation: ConversationSummary) => {
|
const upsertConversation = useCallback((conversation: ConversationSummary) => {
|
||||||
setConversations((previous) => {
|
setConversations((previous) => {
|
||||||
@ -500,6 +788,10 @@ export default function WorkspaceShell() {
|
|||||||
setConversations((previous) => previous.filter((conversation) => conversation.id !== conversationId));
|
setConversations((previous) => previous.filter((conversation) => conversation.id !== conversationId));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const removeComposerAttachment = useCallback((attachmentId: string) => {
|
||||||
|
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyConversationPayload = useCallback(
|
const applyConversationPayload = useCallback(
|
||||||
(conversation: ConversationDetail) => {
|
(conversation: ConversationDetail) => {
|
||||||
setActiveConversation(conversation);
|
setActiveConversation(conversation);
|
||||||
@ -800,16 +1092,157 @@ export default function WorkspaceShell() {
|
|||||||
[applyConversationPayload, router],
|
[applyConversationPayload, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePickAttachments = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAttachmentChange = useCallback(
|
||||||
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composerAttachments.length + files.length > MAX_ATTACHMENTS) {
|
||||||
|
setNotice({
|
||||||
|
type: 'error',
|
||||||
|
message: `You can attach up to ${MAX_ATTACHMENTS} files per message.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotice(null);
|
||||||
|
setUploadingAttachment(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextAttachments: ComposerAttachment[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const extractedText = await readAttachmentText(file);
|
||||||
|
const uploadedFile = await FileUploader.upload(ATTACHMENT_UPLOAD_PATH, file, {
|
||||||
|
size: MAX_ATTACHMENT_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextAttachments.push({
|
||||||
|
id: uploadedFile.id,
|
||||||
|
kind: file.type.startsWith('image/') ? 'image' : 'file',
|
||||||
|
filename: uploadedFile.name,
|
||||||
|
mime_type: file.type || 'application/octet-stream',
|
||||||
|
size_bytes: uploadedFile.sizeInBytes || file.size,
|
||||||
|
storage_key: uploadedFile.privateUrl || '',
|
||||||
|
notes: extractedText,
|
||||||
|
upload: uploadedFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setComposerAttachments((previous) => [...previous, ...nextAttachments]);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
type: 'error',
|
||||||
|
message: getErrorMessage(error, 'Failed to attach the file.'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploadingAttachment(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[composerAttachments.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopVoiceCapture = useCallback(() => {
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
recognitionRef.current.stop();
|
||||||
|
recognitionRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsListening(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVoiceInput = useCallback(() => {
|
||||||
|
if (isListening) {
|
||||||
|
stopVoiceCapture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpeechRecognitionConstructor = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
|
||||||
|
if (!SpeechRecognitionConstructor) {
|
||||||
|
setNotice({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Voice input is not supported in this browser.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recognition = new SpeechRecognitionConstructor();
|
||||||
|
const initialValue = composer.trim();
|
||||||
|
let lastTranscript = '';
|
||||||
|
|
||||||
|
recognition.continuous = false;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = 'en-US';
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
let transcript = '';
|
||||||
|
|
||||||
|
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
||||||
|
transcript += event.results[index][0]?.transcript || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTranscript = transcript.trim();
|
||||||
|
|
||||||
|
if (!normalizedTranscript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedTranscript === lastTranscript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTranscript = normalizedTranscript;
|
||||||
|
|
||||||
|
if (!initialValue) {
|
||||||
|
setComposer(normalizedTranscript);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setComposer(`${initialValue} ${normalizedTranscript}`.trim());
|
||||||
|
};
|
||||||
|
recognition.onerror = () => {
|
||||||
|
setNotice({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Voice input failed. Try speaking again or use the keyboard.',
|
||||||
|
});
|
||||||
|
setIsListening(false);
|
||||||
|
recognitionRef.current = null;
|
||||||
|
};
|
||||||
|
recognition.onend = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
recognitionRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
setNotice(null);
|
||||||
|
setIsListening(true);
|
||||||
|
recognition.start();
|
||||||
|
}, [composer, isListening, stopVoiceCapture]);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(async () => {
|
const handleSendMessage = useCallback(async () => {
|
||||||
const messageToSend = composer.trim();
|
const messageToSend = composer.trim();
|
||||||
|
const attachmentsToSend = composerAttachments;
|
||||||
|
|
||||||
if (!messageToSend || sending) {
|
if ((!messageToSend && !attachmentsToSend.length) || sending || uploadingAttachment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setComposer('');
|
setComposer('');
|
||||||
|
setComposerAttachments([]);
|
||||||
|
|
||||||
let conversation = activeConversation;
|
let conversation = activeConversation;
|
||||||
let createdConversationId: string | null = null;
|
let createdConversationId: string | null = null;
|
||||||
@ -832,6 +1265,16 @@ export default function WorkspaceShell() {
|
|||||||
content_markdown: messageToSend,
|
content_markdown: messageToSend,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
optimistic: true,
|
optimistic: true,
|
||||||
|
attachments: attachmentsToSend.map((attachment) => ({
|
||||||
|
id: attachment.id,
|
||||||
|
kind: attachment.kind,
|
||||||
|
filename: attachment.filename,
|
||||||
|
mime_type: attachment.mime_type,
|
||||||
|
size_bytes: attachment.size_bytes,
|
||||||
|
storage_key: attachment.storage_key,
|
||||||
|
notes: attachment.notes,
|
||||||
|
file: attachment.upload,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `optimistic-assistant-${optimisticBase}`,
|
id: `optimistic-assistant-${optimisticBase}`,
|
||||||
@ -844,6 +1287,16 @@ export default function WorkspaceShell() {
|
|||||||
|
|
||||||
const { data } = await axios.post(`/workspace/conversations/${conversation.id}/messages`, {
|
const { data } = await axios.post(`/workspace/conversations/${conversation.id}/messages`, {
|
||||||
content: messageToSend,
|
content: messageToSend,
|
||||||
|
attachments: attachmentsToSend.map((attachment) => ({
|
||||||
|
kind: attachment.kind,
|
||||||
|
filename: attachment.filename,
|
||||||
|
mime_type: attachment.mime_type,
|
||||||
|
size_bytes: attachment.size_bytes,
|
||||||
|
storage_key: attachment.storage_key,
|
||||||
|
notes: attachment.notes,
|
||||||
|
file: attachment.kind === 'file' ? [attachment.upload] : [],
|
||||||
|
image: attachment.kind === 'image' ? [attachment.upload] : [],
|
||||||
|
})),
|
||||||
agentId: conversation.agent?.id || selectedAgentId || undefined,
|
agentId: conversation.agent?.id || selectedAgentId || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -851,6 +1304,7 @@ export default function WorkspaceShell() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
setComposer(messageToSend);
|
setComposer(messageToSend);
|
||||||
|
setComposerAttachments(attachmentsToSend);
|
||||||
setNotice({
|
setNotice({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: getErrorMessage(error, 'Failed to send the message.'),
|
message: getErrorMessage(error, 'Failed to send the message.'),
|
||||||
@ -871,9 +1325,11 @@ export default function WorkspaceShell() {
|
|||||||
applyConversationResponse,
|
applyConversationResponse,
|
||||||
applyConversationPayload,
|
applyConversationPayload,
|
||||||
composer,
|
composer,
|
||||||
|
composerAttachments,
|
||||||
router,
|
router,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
sending,
|
sending,
|
||||||
|
uploadingAttachment,
|
||||||
upsertConversation,
|
upsertConversation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -961,6 +1417,14 @@ export default function WorkspaceShell() {
|
|||||||
activeConversation ? 'h-full min-h-0 overflow-hidden' : 'min-h-[calc(100vh-3rem)]'
|
activeConversation ? 'h-full min-h-0 overflow-hidden' : 'min-h-[calc(100vh-3rem)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
accept="*/*"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={handleAttachmentChange}
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
<aside
|
<aside
|
||||||
className={`absolute inset-y-0 left-0 z-20 flex w-full max-w-[220px] flex-col border-r border-slate-200 bg-[#fafafa] transition duration-200 lg:static lg:translate-x-0 ${
|
className={`absolute inset-y-0 left-0 z-20 flex w-full max-w-[220px] flex-col border-r border-slate-200 bg-[#fafafa] transition duration-200 lg:static lg:translate-x-0 ${
|
||||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
@ -1290,6 +1754,58 @@ export default function WorkspaceShell() {
|
|||||||
|
|
||||||
<div className="border-t border-slate-200 bg-white px-3.5 py-2 md:px-4">
|
<div className="border-t border-slate-200 bg-white px-3.5 py-2 md:px-4">
|
||||||
<div className="rounded-[8px] border border-slate-200 bg-white p-2.5">
|
<div className="rounded-[8px] border border-slate-200 bg-white p-2.5">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] font-medium text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={uploadingAttachment || composerAttachments.length >= MAX_ATTACHMENTS}
|
||||||
|
onClick={handlePickAttachments}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiPaperclip} size={15} />
|
||||||
|
{uploadingAttachment ? 'Uploading…' : 'Attach file'}
|
||||||
|
</button>
|
||||||
|
{speechSupported ? (
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-[8px] border px-2.5 py-1.5 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
|
isListening
|
||||||
|
? 'border-slate-900 bg-slate-900 text-white'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600'
|
||||||
|
}`}
|
||||||
|
disabled={uploadingAttachment}
|
||||||
|
onClick={handleVoiceInput}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BaseIcon path={isListening ? mdiStopCircleOutline : mdiMicrophoneOutline} size={15} />
|
||||||
|
{isListening ? 'Stop voice' : 'Voice input'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
{composerAttachments.length
|
||||||
|
? `${composerAttachments.length}/${MAX_ATTACHMENTS} attached`
|
||||||
|
: 'Add files or dictate a message'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{composerAttachments.length ? (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
|
{composerAttachments.map((attachment) => (
|
||||||
|
<AttachmentChip
|
||||||
|
attachment={{
|
||||||
|
id: attachment.id,
|
||||||
|
kind: attachment.kind,
|
||||||
|
filename: attachment.filename,
|
||||||
|
file: attachment.upload,
|
||||||
|
size_bytes: attachment.size_bytes,
|
||||||
|
}}
|
||||||
|
key={attachment.id}
|
||||||
|
onRemove={removeComposerAttachment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1fr)_165px]">
|
<div className="grid gap-2 lg:grid-cols-[minmax(0,1fr)_165px]">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-[62px] w-full resize-none rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] leading-5 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
|
className="min-h-[62px] w-full resize-none rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] leading-5 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
|
||||||
@ -1325,7 +1841,7 @@ export default function WorkspaceShell() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center justify-center gap-1.5 rounded-[8px] bg-slate-900 px-3.5 py-2 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
|
className="inline-flex items-center justify-center gap-1.5 rounded-[8px] bg-slate-900 px-3.5 py-2 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
disabled={!composer.trim() || sending}
|
disabled={!canSubmitMessage || sending || uploadingAttachment}
|
||||||
onClick={() => void handleSendMessage()}
|
onClick={() => void handleSendMessage()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@ -1411,7 +1927,7 @@ export default function WorkspaceShell() {
|
|||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-[10px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
|
className="inline-flex items-center justify-center gap-2 rounded-[10px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
disabled={!composer.trim() || sending || loadingBootstrap}
|
disabled={!canSubmitMessage || sending || loadingBootstrap || uploadingAttachment}
|
||||||
onClick={() => void handleSendMessage()}
|
onClick={() => void handleSendMessage()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@ -1426,6 +1942,57 @@ export default function WorkspaceShell() {
|
|||||||
<p className="mb-2.5 text-[12px] leading-5 text-slate-500">
|
<p className="mb-2.5 text-[12px] leading-5 text-slate-500">
|
||||||
Press Enter to start. Use Shift+Enter for a new line.
|
Press Enter to start. Use Shift+Enter for a new line.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] font-medium text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={uploadingAttachment || composerAttachments.length >= MAX_ATTACHMENTS}
|
||||||
|
onClick={handlePickAttachments}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiPaperclip} size={15} />
|
||||||
|
{uploadingAttachment ? 'Uploading…' : 'Attach file'}
|
||||||
|
</button>
|
||||||
|
{speechSupported ? (
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-[8px] border px-2.5 py-1.5 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
|
isListening
|
||||||
|
? 'border-slate-900 bg-slate-900 text-white'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600'
|
||||||
|
}`}
|
||||||
|
disabled={uploadingAttachment}
|
||||||
|
onClick={handleVoiceInput}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BaseIcon path={isListening ? mdiStopCircleOutline : mdiMicrophoneOutline} size={15} />
|
||||||
|
{isListening ? 'Stop voice' : 'Voice input'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
{composerAttachments.length
|
||||||
|
? `${composerAttachments.length}/${MAX_ATTACHMENTS} attached`
|
||||||
|
: 'Files and dictation are optional'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{composerAttachments.length ? (
|
||||||
|
<div className="mb-2.5 flex flex-wrap gap-2">
|
||||||
|
{composerAttachments.map((attachment) => (
|
||||||
|
<AttachmentChip
|
||||||
|
attachment={{
|
||||||
|
id: attachment.id,
|
||||||
|
kind: attachment.kind,
|
||||||
|
filename: attachment.filename,
|
||||||
|
file: attachment.upload,
|
||||||
|
size_bytes: attachment.size_bytes,
|
||||||
|
}}
|
||||||
|
key={attachment.id}
|
||||||
|
onRemove={removeComposerAttachment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-[104px] w-full resize-none rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-[14px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
|
className="min-h-[104px] w-full resize-none rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-[14px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
|
||||||
onChange={(event) => setComposer(event.target.value)}
|
onChange={(event) => setComposer(event.target.value)}
|
||||||
|
|||||||
@ -10,6 +10,17 @@ body {
|
|||||||
@apply w-screen transition-position lg:w-auto h-full flex flex-col;
|
@apply w-screen transition-position lg:w-auto h-full flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.app-shell-content--with-sidebar {
|
||||||
|
padding-left: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-navbar--with-sidebar {
|
||||||
|
left: 16rem;
|
||||||
|
width: calc(100% - 16rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
@apply cursor-pointer;
|
@apply cursor-pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
|
|
||||||
type CompilationStatus = 'ready' | 'compiling' | 'error' | 'initial';
|
|
||||||
|
|
||||||
const useDevCompilationStatus = (): CompilationStatus => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [status, setStatus] = useState<CompilationStatus>('initial');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
|
||||||
setStatus('ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRouteChangeStart = () => {
|
|
||||||
setStatus('compiling');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRouteChangeComplete = () => {
|
|
||||||
setTimeout(() => setStatus('ready'), 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRouteChangeError = () => {
|
|
||||||
setTimeout(() => setStatus('error'), 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
router.events.on('routeChangeStart', handleRouteChangeStart);
|
|
||||||
router.events.on('routeChangeComplete', handleRouteChangeComplete);
|
|
||||||
router.events.on('routeChangeError', handleRouteChangeError);
|
|
||||||
|
|
||||||
setStatus('ready');
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
router.events.off('routeChangeStart', handleRouteChangeStart);
|
|
||||||
router.events.off('routeChangeComplete', handleRouteChangeComplete);
|
|
||||||
router.events.off('routeChangeError', handleRouteChangeError);
|
|
||||||
};
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return status;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDevCompilationStatus;
|
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import React, { ReactNode, useEffect, useRef, useState } from 'react'
|
import React, { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import {
|
import {
|
||||||
|
mdiMenu,
|
||||||
|
mdiClose,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
mdiCogOutline,
|
mdiCogOutline,
|
||||||
mdiLogout,
|
mdiLogout,
|
||||||
@ -8,6 +10,7 @@ import {
|
|||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
import BaseIcon from '../components/BaseIcon'
|
import BaseIcon from '../components/BaseIcon'
|
||||||
import NavBar from '../components/NavBar'
|
import NavBar from '../components/NavBar'
|
||||||
|
import NavBarItemPlain from '../components/NavBarItemPlain'
|
||||||
import AsideMenu from '../components/AsideMenu'
|
import AsideMenu from '../components/AsideMenu'
|
||||||
import FooterBar from '../components/FooterBar'
|
import FooterBar from '../components/FooterBar'
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
@ -111,13 +114,16 @@ export default function LayoutAuthenticated({
|
|||||||
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
|
||||||
|
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||||
const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false)
|
const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false)
|
||||||
|
const [viewportWidth, setViewportWidth] = useState(0)
|
||||||
const workspaceAccountMenuButtonRef = useRef(null)
|
const workspaceAccountMenuButtonRef = useRef(null)
|
||||||
const currentUserAvatarUrl = getAvatarUrl(currentUser?.avatar)
|
const currentUserAvatarUrl = getAvatarUrl(currentUser?.avatar)
|
||||||
const currentUserInitial = getUserInitial(currentUser)
|
const currentUserInitial = getUserInitial(currentUser)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => {
|
const handleRouteChangeStart = () => {
|
||||||
|
setIsAsideMobileExpanded(false)
|
||||||
setIsWorkspaceAccountMenuOpen(false)
|
setIsWorkspaceAccountMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,12 +136,32 @@ export default function LayoutAuthenticated({
|
|||||||
}
|
}
|
||||||
}, [router.events, dispatch])
|
}, [router.events, dispatch])
|
||||||
|
|
||||||
const contentStyle = isWorkspaceRoute
|
useEffect(() => {
|
||||||
? undefined
|
if (typeof window === 'undefined') {
|
||||||
: { paddingLeft: `${asideWidth}px` }
|
return;
|
||||||
const navStyle = isWorkspaceRoute
|
}
|
||||||
? undefined
|
|
||||||
: { left: `${asideWidth}px`, width: `calc(100% - ${asideWidth}px)` }
|
const syncViewportWidth = () => {
|
||||||
|
setViewportWidth(window.innerWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncViewportWidth()
|
||||||
|
window.addEventListener('resize', syncViewportWidth)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', syncViewportWidth)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAsideToggle = () => {
|
||||||
|
setIsAsideMobileExpanded((previous) => !previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPersistentAside = !isWorkspaceRoute && viewportWidth >= 640
|
||||||
|
const contentStyle = hasPersistentAside ? { paddingLeft: `${asideWidth}px` } : undefined
|
||||||
|
const navStyle = hasPersistentAside
|
||||||
|
? { left: `${asideWidth}px`, width: `calc(100% - ${asideWidth}px)` }
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (!isAuthBootstrapped) {
|
if (!isAuthBootstrapped) {
|
||||||
return (
|
return (
|
||||||
@ -204,6 +230,11 @@ export default function LayoutAuthenticated({
|
|||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-between px-3 sm:px-5">
|
<div className="flex min-w-0 flex-1 items-center justify-between px-3 sm:px-5">
|
||||||
<div className="flex min-w-0 items-center">
|
<div className="flex min-w-0 items-center">
|
||||||
|
{!isWorkspaceRoute && (
|
||||||
|
<NavBarItemPlain display="flex sm:hidden" onClick={handleAsideToggle}>
|
||||||
|
<BaseIcon path={isAsideMobileExpanded ? mdiClose : mdiMenu} size="22" />
|
||||||
|
</NavBarItemPlain>
|
||||||
|
)}
|
||||||
{isWorkspaceRoute && (
|
{isWorkspaceRoute && (
|
||||||
<Link
|
<Link
|
||||||
className="truncate text-[14px] font-semibold tracking-[-0.02em] text-slate-700 dark:text-slate-100"
|
className="truncate text-[14px] font-semibold tracking-[-0.02em] text-slate-700 dark:text-slate-100"
|
||||||
@ -274,7 +305,9 @@ export default function LayoutAuthenticated({
|
|||||||
</NavBar>
|
</NavBar>
|
||||||
{!isWorkspaceRoute && (
|
{!isWorkspaceRoute && (
|
||||||
<AsideMenu
|
<AsideMenu
|
||||||
|
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||||
menu={menuAside}
|
menu={menuAside}
|
||||||
|
onAsideMobileClose={() => setIsAsideMobileExpanded(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
128
frontend/src/lib/billingPlans.ts
Normal file
128
frontend/src/lib/billingPlans.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
export type BillingPlan = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
priceMonthly: number;
|
||||||
|
currency: string;
|
||||||
|
stripeProductId?: string;
|
||||||
|
stripePriceId?: string;
|
||||||
|
messageLimit?: number | null;
|
||||||
|
agentLimit?: number | null;
|
||||||
|
isFeatured?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
features: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillingSubscription = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
currentPeriodStart?: string;
|
||||||
|
currentPeriodEnd?: string;
|
||||||
|
cancelAtPeriodEnd?: boolean;
|
||||||
|
plan: BillingPlan | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StripeSettingsPlan = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
stripeProductId?: string;
|
||||||
|
stripePriceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StripeSettingsState = {
|
||||||
|
stripeSecretKey: string;
|
||||||
|
stripeSecretKeyPreview: string;
|
||||||
|
stripeSecretKeySource: string;
|
||||||
|
stripeWebhookSecret: string;
|
||||||
|
stripeWebhookSecretPreview: string;
|
||||||
|
stripeWebhookSecretSource: string;
|
||||||
|
webhookEndpoint: string;
|
||||||
|
webhookEvents: string[];
|
||||||
|
plans: StripeSettingsPlan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const marketingPlans: BillingPlan[] = [
|
||||||
|
{
|
||||||
|
name: 'Starter',
|
||||||
|
slug: 'starter',
|
||||||
|
description: 'For solo builders who want a calm workspace and the basics of AI billing.',
|
||||||
|
priceMonthly: 19,
|
||||||
|
currency: 'usd',
|
||||||
|
stripeProductId: 'prod_mock_starter',
|
||||||
|
stripePriceId: 'price_mock_starter_monthly',
|
||||||
|
messageLimit: 300,
|
||||||
|
agentLimit: 3,
|
||||||
|
isFeatured: false,
|
||||||
|
features: [
|
||||||
|
'300 AI messages every month',
|
||||||
|
'3 custom agents',
|
||||||
|
'Conversation history and export',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pro',
|
||||||
|
slug: 'pro',
|
||||||
|
description: 'For frequent users who need more messages, more agents, and a stronger daily workflow.',
|
||||||
|
priceMonthly: 49,
|
||||||
|
currency: 'usd',
|
||||||
|
stripeProductId: 'prod_mock_pro',
|
||||||
|
stripePriceId: 'price_mock_pro_monthly',
|
||||||
|
messageLimit: 1500,
|
||||||
|
agentLimit: 10,
|
||||||
|
isFeatured: true,
|
||||||
|
features: [
|
||||||
|
'1,500 AI messages every month',
|
||||||
|
'10 custom agents',
|
||||||
|
'Priority billing support',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Team',
|
||||||
|
slug: 'team',
|
||||||
|
description: 'For heavier shared use with more throughput and room for longer-running work.',
|
||||||
|
priceMonthly: 149,
|
||||||
|
currency: 'usd',
|
||||||
|
stripeProductId: 'prod_mock_team',
|
||||||
|
stripePriceId: 'price_mock_team_monthly',
|
||||||
|
messageLimit: 5000,
|
||||||
|
agentLimit: 30,
|
||||||
|
isFeatured: false,
|
||||||
|
features: [
|
||||||
|
'5,000 AI messages every month',
|
||||||
|
'30 custom agents',
|
||||||
|
'Shared workspace billing controls',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatBillingPrice(amount: number, currency = 'usd') {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBillingLimit(value: number | null | undefined, label: string) {
|
||||||
|
if (!value) {
|
||||||
|
return `Unlimited ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.toLocaleString()} ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBillingDate(value?: string) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
@ -80,6 +80,11 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
icon: icon.mdiAccountCircle,
|
icon: icon.mdiAccountCircle,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/billing',
|
||||||
|
label: 'Billing',
|
||||||
|
icon: icon.mdiCreditCardOutline,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Admin',
|
label: 'Admin',
|
||||||
icon: icon.mdiCogOutline,
|
icon: icon.mdiCogOutline,
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import axios from 'axios';
|
|||||||
import { baseURLApi } from '../config';
|
import { baseURLApi } from '../config';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import ErrorBoundary from "../components/ErrorBoundary";
|
import ErrorBoundary from "../components/ErrorBoundary";
|
||||||
import DevModeBadge from '../components/DevModeBadge';
|
|
||||||
import 'intro.js/introjs.css';
|
import 'intro.js/introjs.css';
|
||||||
import { appWithTranslation } from 'next-i18next';
|
import { appWithTranslation } from 'next-i18next';
|
||||||
import '../i18n';
|
import '../i18n';
|
||||||
@ -231,7 +230,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
stepsEnabled={stepsEnabled}
|
stepsEnabled={stepsEnabled}
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
/>
|
/>
|
||||||
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|||||||
@ -1,689 +1,196 @@
|
|||||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
|
import { mdiArrowLeft, mdiRobotOutline } from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import Link from 'next/link';
|
||||||
import DatePicker from "react-datepicker";
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
import { Form, Formik } from 'formik';
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox'
|
import AgentFormSections from '../../components/Agents/AgentFormSections';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain';
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import { getPageTitle } from '../../config';
|
||||||
import { getPageTitle } from '../../config'
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch, update } from '../../stores/agents/agentsSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
import { Field, Form, Formik } from 'formik'
|
const actionButtonClassName =
|
||||||
import FormField from '../../components/FormField'
|
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
|
||||||
import BaseDivider from '../../components/BaseDivider'
|
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
|
||||||
import BaseButton from '../../components/BaseButton'
|
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
|
||||||
import { SelectField } from "../../components/SelectField";
|
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/agents/agentsSlice'
|
const initialAgentValues = {
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
description: '',
|
||||||
import { useRouter } from 'next/router'
|
is_active: true,
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
is_default: false,
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
max_output_tokens: '',
|
||||||
import ImageField from "../../components/ImageField";
|
metadata_json: '',
|
||||||
|
model: '',
|
||||||
|
name: '',
|
||||||
|
system_prompt: '',
|
||||||
|
temperature: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPreviewName(values: typeof initialAgentValues) {
|
||||||
|
if (values.name) {
|
||||||
|
return values.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Untitled agent';
|
||||||
|
}
|
||||||
|
|
||||||
const EditAgentsPage = () => {
|
const EditAgentsPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const initVals = {
|
const { agents, loading } = useAppSelector((state) => state.agents);
|
||||||
|
const { id } = router.query;
|
||||||
|
const [initialValues, setInitialValues] = useState(initialAgentValues);
|
||||||
'name': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
description: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'model': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
system_prompt: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'temperature': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
max_output_tokens: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_default: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_active: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
metadata_json: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
const [initialValues, setInitialValues] = useState(initVals)
|
|
||||||
|
|
||||||
const { agents } = useAppSelector((state) => state.agents)
|
|
||||||
|
|
||||||
|
|
||||||
const { id } = router.query
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id: id }))
|
if (typeof id !== 'string') {
|
||||||
}, [id])
|
return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof agents === 'object') {
|
|
||||||
setInitialValues(agents)
|
|
||||||
}
|
}
|
||||||
}, [agents])
|
|
||||||
|
dispatch(fetch({ id }));
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof agents === 'object') {
|
if (!agents || Array.isArray(agents) || typeof agents !== 'object') {
|
||||||
const newInitialVal = {...initVals};
|
return;
|
||||||
Object.keys(initVals).forEach(el => newInitialVal[el] = (agents)[el])
|
}
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [agents])
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
setInitialValues({
|
||||||
await dispatch(update({ id: id, data }))
|
description: agents.description || '',
|
||||||
await router.push('/agents/agents-list')
|
is_active: Boolean(agents.is_active),
|
||||||
}
|
is_default: Boolean(agents.is_default),
|
||||||
|
max_output_tokens: agents.max_output_tokens ?? '',
|
||||||
|
metadata_json: agents.metadata_json || '',
|
||||||
|
model: agents.model || '',
|
||||||
|
name: agents.name || '',
|
||||||
|
system_prompt: agents.system_prompt || '',
|
||||||
|
temperature: agents.temperature ?? '',
|
||||||
|
});
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const handleSubmit = async (data: typeof initialAgentValues) => {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push('/agents/agents-list');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit agents')}</title>
|
<title>{getPageTitle('Edit agent')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit agents'} main>
|
<Formik
|
||||||
{''}
|
enableReinitialize
|
||||||
</SectionTitleLineWithButton>
|
initialValues={initialValues}
|
||||||
<CardBox>
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
<Formik
|
>
|
||||||
enableReinitialize
|
{({ isSubmitting, setFieldValue, values }) => {
|
||||||
initialValues={initialValues}
|
const previewName = getPreviewName(values);
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
const isAgentLoading = loading && !initialValues.name && !initialValues.model;
|
||||||
>
|
|
||||||
<Form>
|
return (
|
||||||
|
<Form>
|
||||||
|
<div className="flex w-full flex-col gap-5">
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
<FormField
|
<Link
|
||||||
label="Name"
|
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
|
||||||
>
|
href="/agents/agents-list"
|
||||||
<Field
|
>
|
||||||
name="name"
|
<BaseIcon path={mdiArrowLeft} size={14} />
|
||||||
placeholder="Name"
|
Back to agents
|
||||||
/>
|
</Link>
|
||||||
</FormField>
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Agent
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
Refine an existing assistant profile.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
|
Update the prompt, tune the model settings, or change how this agent shows up in the workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<AgentFormSections setFieldValue={setFieldValue} values={values} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
|
||||||
|
<BaseIcon path={mdiRobotOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[16px] font-semibold text-slate-900">
|
||||||
|
{previewName}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">
|
||||||
|
{values.model || 'No model selected yet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<FormField label="Description" hasTextareaHeight>
|
<span
|
||||||
<Field name="description" as="textarea" placeholder="Description" />
|
className={`inline-flex items-center rounded-[999px] px-2.5 py-1 text-[11px] font-medium ${
|
||||||
</FormField>
|
values.is_active
|
||||||
|
? 'bg-emerald-50 text-emerald-700'
|
||||||
|
: 'bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{values.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-[999px] bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-700">
|
||||||
|
{values.is_default ? 'Default preset' : 'Custom preset'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-[13px] leading-6 text-slate-500">
|
||||||
|
{values.description || 'Add a short description so people understand when to use this agent.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Actions
|
||||||
|
</p>
|
||||||
|
{isAgentLoading && (
|
||||||
|
<p className="mt-4 text-[13px] text-slate-500">Loading agent…</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/agents/agents-list"
|
||||||
|
>
|
||||||
<FormField
|
Cancel
|
||||||
label="Model"
|
</Link>
|
||||||
>
|
<button
|
||||||
<Field
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
name="model"
|
disabled={isSubmitting || isAgentLoading}
|
||||||
placeholder="Model"
|
type="submit"
|
||||||
/>
|
>
|
||||||
</FormField>
|
{isSubmitting ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Systemprompt" hasTextareaHeight>
|
|
||||||
<Field name="system_prompt" as="textarea" placeholder="Systemprompt" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Temperature"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="number"
|
|
||||||
name="temperature"
|
|
||||||
placeholder="Temperature"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Maxoutputtokens"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="number"
|
|
||||||
name="max_output_tokens"
|
|
||||||
placeholder="Maxoutputtokens"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Isdefault' labelFor='is_default'>
|
|
||||||
<Field
|
|
||||||
name='is_default'
|
|
||||||
id='is_default'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Isactive' labelFor='is_active'>
|
|
||||||
<Field
|
|
||||||
name='is_active'
|
|
||||||
id='is_active'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="MetadataJSON" hasTextareaHeight>
|
|
||||||
<Field name="metadata_json" as="textarea" placeholder="MetadataJSON" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/agents/agents-list')}/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EditAgentsPage.getLayout = function getLayout(page: ReactElement) {
|
EditAgentsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="UPDATE_AGENTS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'UPDATE_AGENTS'}
|
export default EditAgentsPage;
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EditAgentsPage
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { uniqueId } from 'lodash';
|
|||||||
import React, { ReactElement, useState } from 'react';
|
import React, { ReactElement, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox';
|
|
||||||
import CardBoxModal from '../../components/CardBoxModal';
|
import CardBoxModal from '../../components/CardBoxModal';
|
||||||
import DragDropFilePicker from '../../components/DragDropFilePicker';
|
import DragDropFilePicker from '../../components/DragDropFilePicker';
|
||||||
import TableAgents from '../../components/Agents/TableAgents';
|
import TableAgents from '../../components/Agents/TableAgents';
|
||||||
@ -136,14 +135,14 @@ const AgentsTablesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
|
<div className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none">
|
||||||
<TableAgents
|
<TableAgents
|
||||||
filterItems={filterItems}
|
filterItems={filterItems}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilterItems={setFilterItems}
|
setFilterItems={setFilterItems}
|
||||||
showGrid={false}
|
showGrid={false}
|
||||||
/>
|
/>
|
||||||
</CardBox>
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
<CardBoxModal
|
<CardBoxModal
|
||||||
buttonColor="info"
|
buttonColor="info"
|
||||||
|
|||||||
@ -1,536 +1,155 @@
|
|||||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
import { mdiArrowLeft, mdiRobotOutline } from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head';
|
||||||
import React, { ReactElement } from 'react'
|
import Link from 'next/link';
|
||||||
import CardBox from '../../components/CardBox'
|
import React, { ReactElement } from 'react';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import { Form, Formik } from 'formik';
|
||||||
import SectionMain from '../../components/SectionMain'
|
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
|
||||||
import { getPageTitle } from '../../config'
|
|
||||||
|
|
||||||
import { Field, Form, Formik } from 'formik'
|
import AgentFormSections from '../../components/Agents/AgentFormSections';
|
||||||
import FormField from '../../components/FormField'
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
import BaseDivider from '../../components/BaseDivider'
|
import SectionMain from '../../components/SectionMain';
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
import { getPageTitle } from '../../config';
|
||||||
import BaseButton from '../../components/BaseButton'
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
import { create } from '../../stores/agents/agentsSlice';
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
import { useRouter } from 'next/router';
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
|
||||||
|
|
||||||
import { SelectField } from '../../components/SelectField'
|
const actionButtonClassName =
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { create } from '../../stores/agents/agentsSlice'
|
|
||||||
import { useAppDispatch } from '../../stores/hooks'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import moment from 'moment';
|
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
|
description: '',
|
||||||
|
is_active: true,
|
||||||
name: '',
|
is_default: false,
|
||||||
|
max_output_tokens: '',
|
||||||
|
metadata_json: '',
|
||||||
|
model: '',
|
||||||
|
name: '',
|
||||||
|
system_prompt: '',
|
||||||
|
temperature: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPreviewName(values: typeof initialValues) {
|
||||||
|
if (values.name) {
|
||||||
|
return values.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Untitled agent';
|
||||||
|
|
||||||
|
|
||||||
description: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
system_prompt: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
temperature: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
max_output_tokens: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_default: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_active: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
metadata_json: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const AgentsNew = () => {
|
const AgentsNew = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleSubmit = async (data: typeof initialValues) => {
|
||||||
|
await dispatch(create(data));
|
||||||
|
await router.push('/agents/agents-list');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
|
||||||
await dispatch(create(data))
|
|
||||||
await router.push('/agents/agents-list')
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('New Item')}</title>
|
<title>{getPageTitle('New agent')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<Formik initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
||||||
{''}
|
{({ isSubmitting, setFieldValue, values }) => {
|
||||||
</SectionTitleLineWithButton>
|
const previewName = getPreviewName(values);
|
||||||
<CardBox>
|
|
||||||
<Formik
|
return (
|
||||||
initialValues={
|
<Form>
|
||||||
|
<div className="flex w-full flex-col gap-5">
|
||||||
initialValues
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<Link
|
||||||
}
|
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
href="/agents/agents-list"
|
||||||
>
|
>
|
||||||
<Form>
|
<BaseIcon path={mdiArrowLeft} size={14} />
|
||||||
|
Back to agents
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
<FormField
|
Agent
|
||||||
label="Name"
|
</p>
|
||||||
>
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
<Field
|
Create a reusable assistant profile.
|
||||||
name="name"
|
</h1>
|
||||||
placeholder="Name"
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
/>
|
Define how this agent should behave, what model it should use, and whether it belongs in the default workspace lineup.
|
||||||
</FormField>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<AgentFormSections setFieldValue={setFieldValue} values={values} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
|
||||||
|
<BaseIcon path={mdiRobotOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[16px] font-semibold text-slate-900">
|
||||||
|
{previewName}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">
|
||||||
|
{values.model || 'No model selected yet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-[999px] px-2.5 py-1 text-[11px] font-medium ${
|
||||||
|
values.is_active
|
||||||
|
? 'bg-emerald-50 text-emerald-700'
|
||||||
|
: 'bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
<FormField label="Description" hasTextareaHeight>
|
>
|
||||||
<Field name="description" as="textarea" placeholder="Description" />
|
{values.is_active ? 'Active' : 'Inactive'}
|
||||||
</FormField>
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-[999px] bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-700">
|
||||||
|
{values.is_default ? 'Default preset' : 'Custom preset'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-[13px] leading-6 text-slate-500">
|
||||||
|
{values.description || 'Add a short description so people understand when to use this agent.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Actions
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/agents/agents-list"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
<FormField
|
{isSubmitting ? 'Creating…' : 'Create agent'}
|
||||||
label="Model"
|
</button>
|
||||||
>
|
</div>
|
||||||
<Field
|
</div>
|
||||||
name="model"
|
</div>
|
||||||
placeholder="Model"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</FormField>
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Systemprompt" hasTextareaHeight>
|
|
||||||
<Field name="system_prompt" as="textarea" placeholder="Systemprompt" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Temperature"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="number"
|
|
||||||
name="temperature"
|
|
||||||
placeholder="Temperature"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Maxoutputtokens"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="number"
|
|
||||||
name="max_output_tokens"
|
|
||||||
placeholder="Maxoutputtokens"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Isdefault' labelFor='is_default'>
|
|
||||||
<Field
|
|
||||||
name='is_default'
|
|
||||||
id='is_default'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Isactive' labelFor='is_active'>
|
|
||||||
<Field
|
|
||||||
name='is_active'
|
|
||||||
id='is_active'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="MetadataJSON" hasTextareaHeight>
|
|
||||||
<Field name="metadata_json" as="textarea" placeholder="MetadataJSON" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/agents/agents-list')}/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
AgentsNew.getLayout = function getLayout(page: ReactElement) {
|
AgentsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="CREATE_AGENTS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'CREATE_AGENTS'}
|
export default AgentsNew;
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AgentsNew
|
|
||||||
|
|||||||
@ -1,612 +1,358 @@
|
|||||||
|
import {
|
||||||
|
mdiArrowLeft,
|
||||||
|
mdiChartTimelineVariant,
|
||||||
|
mdiPencilOutline,
|
||||||
|
mdiRobotOutline,
|
||||||
|
mdiTextBoxOutline,
|
||||||
|
mdiTuneVariant,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
import React, { ReactElement, useEffect } from 'react';
|
import React, { ReactElement, useEffect } from 'react';
|
||||||
import Head from 'next/head'
|
|
||||||
import DatePicker from "react-datepicker";
|
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import { fetch } from '../../stores/agents/agentsSlice'
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
import LayoutAuthenticated from "../../layouts/Authenticated";
|
|
||||||
import {getPageTitle} from "../../config";
|
|
||||||
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
|
||||||
import SectionMain from "../../components/SectionMain";
|
|
||||||
import CardBox from "../../components/CardBox";
|
|
||||||
import BaseButton from "../../components/BaseButton";
|
|
||||||
import BaseDivider from "../../components/BaseDivider";
|
|
||||||
import {mdiChartTimelineVariant} from "@mdi/js";
|
|
||||||
import {SwitchField} from "../../components/SwitchField";
|
|
||||||
import FormField from "../../components/FormField";
|
|
||||||
|
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch } from '../../stores/agents/agentsSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
const actionButtonClassName =
|
||||||
|
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return 'No date';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemperature(value: any) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return 'Auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenLimit(value: any) {
|
||||||
|
if (!value) {
|
||||||
|
return 'Default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value} max tokens`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelatedItem({
|
||||||
|
href,
|
||||||
|
meta,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
meta: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="flex items-start justify-between gap-4 rounded-[10px] border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300"
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[14px] font-medium text-slate-900">{title}</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-slate-500">{meta}</p>
|
||||||
|
</div>
|
||||||
|
<BaseIcon className="shrink-0 text-slate-400" path={mdiChartTimelineVariant} size={16} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const AgentsView = () => {
|
const AgentsView = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const { agents } = useAppSelector((state) => state.agents)
|
const { agents, loading } = useAppSelector((state) => state.agents);
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const { id } = router.query;
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
function removeLastCharacter(str) {
|
|
||||||
console.log(str,`str`)
|
|
||||||
return str.slice(0, -1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
dispatch(fetch({ id }));
|
||||||
dispatch(fetch({ id }));
|
}, [dispatch, id]);
|
||||||
}, [dispatch, id]);
|
|
||||||
|
|
||||||
|
const agent = !Array.isArray(agents) && agents && typeof agents === 'object' ? agents : null;
|
||||||
|
const metadata = parseMetadata(agent?.metadata_json || '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('View agents')}</title>
|
<title>{getPageTitle('Agent details')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View agents')} main>
|
<div className="flex w-full flex-col gap-5">
|
||||||
<BaseButton
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
color='info'
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
label='Edit'
|
<div>
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
|
||||||
|
href="/agents/agents-list"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiArrowLeft} size={14} />
|
||||||
|
Back to agents
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Agent
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
{agent?.name || 'Agent details'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
|
Review the prompt, model behavior, and recent activity linked to this assistant profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/agents/agents-list"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
href={`/agents/agents-edit/?id=${id}`}
|
href={`/agents/agents-edit/?id=${id}`}
|
||||||
/>
|
>
|
||||||
</SectionTitleLineWithButton>
|
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
|
||||||
<CardBox>
|
Edit agent
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !agent && (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !agent && (
|
||||||
|
<div className="rounded-[12px] border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
No data
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-slate-900">
|
||||||
|
We could not load this agent.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-[14px] leading-6 text-slate-500">
|
||||||
|
Try going back to the agents list and opening it again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
{agent && (
|
||||||
<p className={'block font-bold mb-2'}>Name</p>
|
<>
|
||||||
<p>{agents?.name}</p>
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiRobotOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">Identity</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Basic positioning and the model this assistant runs on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Name
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[16px] font-medium text-slate-900">
|
||||||
|
{agent.name || 'Untitled agent'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Model
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[16px] font-medium text-slate-900">
|
||||||
|
{agent.model || 'Default model'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3 md:col-span-2">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Description
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[14px] leading-6 text-slate-600">
|
||||||
|
{agent.description || 'No description yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiTextBoxOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">System prompt</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
The standing instructions this agent follows in every conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4">
|
||||||
|
<pre className="whitespace-pre-wrap text-[13px] leading-6 text-slate-700">
|
||||||
|
{agent.system_prompt || 'No system prompt yet.'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiTuneVariant} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">Behavior</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Output shaping, default availability, and any structured metadata stored with the agent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Temperature
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[16px] font-medium text-slate-900">
|
||||||
|
{formatTemperature(agent.temperature)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Output
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[16px] font-medium text-slate-900">
|
||||||
|
{formatTokenLimit(agent.max_output_tokens)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Availability
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[16px] font-medium text-slate-900">
|
||||||
|
{agent.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Preset type
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[16px] font-medium text-slate-900">
|
||||||
|
{agent.is_default ? 'Default preset' : 'Custom preset'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3 md:col-span-2">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Metadata JSON
|
||||||
|
</p>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-[13px] leading-6 text-slate-600">
|
||||||
|
{metadata ? JSON.stringify(metadata, null, 2) : agent.metadata_json || 'No metadata.'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Related conversations
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{Array.isArray(agent.conversations_agent) && agent.conversations_agent.length > 0 ? (
|
||||||
|
agent.conversations_agent.map((item: any) => (
|
||||||
|
<RelatedItem
|
||||||
|
key={item.id}
|
||||||
|
href={`/conversations/conversations-view/?id=${item.id}`}
|
||||||
|
meta={`${item.status || 'unknown'} · ${formatDate(item.last_message_at)}`}
|
||||||
|
title={item.title || 'Untitled conversation'}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] leading-6 text-slate-500">
|
||||||
|
No conversations linked to this agent yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Usage events
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{Array.isArray(agent.usage_events_agent) && agent.usage_events_agent.length > 0 ? (
|
||||||
|
agent.usage_events_agent.map((item: any) => (
|
||||||
|
<RelatedItem
|
||||||
|
key={item.id}
|
||||||
|
href={`/usage_events/usage_events-view/?id=${item.id}`}
|
||||||
|
meta={`${item.provider || 'provider'} · ${formatDate(item.occurred_at)}`}
|
||||||
|
title={item.event_type || 'Usage event'}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] leading-6 text-slate-500">
|
||||||
|
No usage activity has been recorded for this agent yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Multi Text' hasTextareaHeight>
|
|
||||||
<textarea className={'w-full'} disabled value={agents?.description} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Model</p>
|
|
||||||
<p>{agents?.model}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Multi Text' hasTextareaHeight>
|
|
||||||
<textarea className={'w-full'} disabled value={agents?.system_prompt} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Temperature</p>
|
|
||||||
<p>{agents?.temperature || 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Maxoutputtokens</p>
|
|
||||||
<p>{agents?.max_output_tokens || 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Isdefault'>
|
|
||||||
<SwitchField
|
|
||||||
field={{name: 'is_default', value: agents?.is_default}}
|
|
||||||
form={{setFieldValue: () => null}}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Isactive'>
|
|
||||||
<SwitchField
|
|
||||||
field={{name: 'is_active', value: agents?.is_active}}
|
|
||||||
form={{setFieldValue: () => null}}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Multi Text' hasTextareaHeight>
|
|
||||||
<textarea className={'w-full'} disabled value={agents?.metadata_json} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<>
|
|
||||||
<p className={'block font-bold mb-2'}>Conversations Agent</p>
|
|
||||||
<CardBox
|
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
|
||||||
hasTable
|
|
||||||
>
|
|
||||||
<div className='overflow-x-auto'>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Title</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Summary</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Status</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Ispinned</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Lastmessageat</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>ClientcontextJSON</th>
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{agents.conversations_agent && Array.isArray(agents.conversations_agent) &&
|
|
||||||
agents.conversations_agent.map((item: any) => (
|
|
||||||
<tr key={item.id} onClick={() => router.push(`/conversations/conversations-view/?id=${item.id}`)}>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="title">
|
|
||||||
{ item.title }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="summary">
|
|
||||||
{ item.summary }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="status">
|
|
||||||
{ item.status }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="is_pinned">
|
|
||||||
{ dataFormatter.booleanFormatter(item.is_pinned) }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="last_message_at">
|
|
||||||
{ dataFormatter.dateTimeFormatter(item.last_message_at) }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="client_context_json">
|
|
||||||
{ item.client_context_json }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{!agents?.conversations_agent?.length && <div className={'text-center py-4'}>No data</div>}
|
|
||||||
</CardBox>
|
|
||||||
</>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<>
|
|
||||||
<p className={'block font-bold mb-2'}>Usage_events Agent</p>
|
|
||||||
<CardBox
|
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
|
||||||
hasTable
|
|
||||||
>
|
|
||||||
<div className='overflow-x-auto'>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Eventtype</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Occurredat</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Inputtokens</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Outputtokens</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Totaltokens</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>CostUSD</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Provider</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Model</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>MetadataJSON</th>
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{agents.usage_events_agent && Array.isArray(agents.usage_events_agent) &&
|
|
||||||
agents.usage_events_agent.map((item: any) => (
|
|
||||||
<tr key={item.id} onClick={() => router.push(`/usage_events/usage_events-view/?id=${item.id}`)}>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="event_type">
|
|
||||||
{ item.event_type }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="occurred_at">
|
|
||||||
{ dataFormatter.dateTimeFormatter(item.occurred_at) }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="input_tokens">
|
|
||||||
{ item.input_tokens }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="output_tokens">
|
|
||||||
{ item.output_tokens }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="total_tokens">
|
|
||||||
{ item.total_tokens }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="cost_usd">
|
|
||||||
{ item.cost_usd }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="provider">
|
|
||||||
{ item.provider }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="model">
|
|
||||||
{ item.model }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="metadata_json">
|
|
||||||
{ item.metadata_json }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{!agents?.usage_events_agent?.length && <div className={'text-center py-4'}>No data</div>}
|
|
||||||
</CardBox>
|
|
||||||
</>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
color='info'
|
|
||||||
label='Back'
|
|
||||||
onClick={() => router.push('/agents/agents-list')}
|
|
||||||
/>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
AgentsView.getLayout = function getLayout(page: ReactElement) {
|
AgentsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="READ_AGENTS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'READ_AGENTS'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AgentsView;
|
export default AgentsView;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,417 +1,208 @@
|
|||||||
|
import {
|
||||||
|
mdiArrowLeft,
|
||||||
|
mdiFileOutline,
|
||||||
|
mdiImageOutline,
|
||||||
|
mdiOpenInNew,
|
||||||
|
mdiPaperclip,
|
||||||
|
mdiPencilOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
import React, { ReactElement, useEffect } from 'react';
|
import React, { ReactElement, useEffect } from 'react';
|
||||||
import Head from 'next/head'
|
|
||||||
import DatePicker from "react-datepicker";
|
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import { fetch } from '../../stores/attachments/attachmentsSlice'
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
import LayoutAuthenticated from "../../layouts/Authenticated";
|
|
||||||
import {getPageTitle} from "../../config";
|
|
||||||
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
|
||||||
import SectionMain from "../../components/SectionMain";
|
|
||||||
import CardBox from "../../components/CardBox";
|
|
||||||
import BaseButton from "../../components/BaseButton";
|
|
||||||
import BaseDivider from "../../components/BaseDivider";
|
|
||||||
import {mdiChartTimelineVariant} from "@mdi/js";
|
|
||||||
import {SwitchField} from "../../components/SwitchField";
|
|
||||||
import FormField from "../../components/FormField";
|
|
||||||
|
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import {
|
||||||
|
actionButtonClassName,
|
||||||
|
EntityAsideCard,
|
||||||
|
EntityEmptyState,
|
||||||
|
EntityLinkCard,
|
||||||
|
EntitySection,
|
||||||
|
EntityValueCard,
|
||||||
|
} from '../../components/AdminEntity/PageKit';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch } from '../../stores/attachments/attachmentsSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
function formatBytes(value: any) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
|
||||||
|
if (!number) {
|
||||||
|
return 'Unknown size';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${number.toLocaleString('en-US')} bytes`;
|
||||||
|
}
|
||||||
|
|
||||||
const AttachmentsView = () => {
|
const AttachmentsView = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const { attachments } = useAppSelector((state) => state.attachments)
|
const { attachments, loading } = useAppSelector((state) => state.attachments);
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const { id } = router.query;
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
function removeLastCharacter(str) {
|
|
||||||
console.log(str,`str`)
|
|
||||||
return str.slice(0, -1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
dispatch(fetch({ id }));
|
||||||
dispatch(fetch({ id }));
|
}, [dispatch, id]);
|
||||||
}, [dispatch, id]);
|
|
||||||
|
|
||||||
|
const attachment =
|
||||||
|
!Array.isArray(attachments) && attachments && typeof attachments === 'object' ? attachments : null;
|
||||||
|
const fileList = Array.isArray(attachment?.file) ? attachment.file : [];
|
||||||
|
const imageList = Array.isArray(attachment?.image) ? attachment.image : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('View attachments')}</title>
|
<title>{getPageTitle('Attachment details')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View attachments')} main>
|
<div className="flex w-full flex-col gap-5">
|
||||||
<BaseButton
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
color='info'
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
label='Edit'
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
|
||||||
|
href="/attachments/attachments-list"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiArrowLeft} size={14} />
|
||||||
|
Back to attachments
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Attachment
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
{attachment?.filename || attachment?.kind || 'Attachment details'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">
|
||||||
|
Review the uploaded asset, linked message, storage metadata, and any notes saved with this attachment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/attachments/attachments-list"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
href={`/attachments/attachments-edit/?id=${id}`}
|
href={`/attachments/attachments-edit/?id=${id}`}
|
||||||
/>
|
>
|
||||||
</SectionTitleLineWithButton>
|
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
|
||||||
<CardBox>
|
Edit attachment
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !attachment && (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !attachment && (
|
||||||
|
<EntityEmptyState
|
||||||
|
description="Try going back to the attachments list and opening this record again."
|
||||||
|
title="We could not load this attachment."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachment && (
|
||||||
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntitySection
|
||||||
<div className={'mb-4'}>
|
description="Basic storage and delivery metadata for this uploaded asset."
|
||||||
<p className={'block font-bold mb-2'}>Message</p>
|
icon={mdiPaperclip}
|
||||||
|
title="Overview"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<EntityValueCard label="Kind" value={attachment.kind || 'Unknown'} />
|
||||||
|
<EntityValueCard label="Filename" value={attachment.filename || 'No filename'} />
|
||||||
|
<EntityValueCard label="MIME type" value={attachment.mime_type || 'Unknown'} />
|
||||||
|
<EntityValueCard label="Size" value={formatBytes(attachment.size_bytes)} />
|
||||||
|
<EntityValueCard label="Storage key" value={attachment.storage_key || 'No storage key'} />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
<p>{attachments?.message?.content ?? 'No data'}</p>
|
description="Optional notes or guidance saved with the asset."
|
||||||
|
icon={mdiFileOutline}
|
||||||
|
title="Notes"
|
||||||
|
>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4">
|
||||||
|
<p className="text-[14px] leading-7 text-slate-700">
|
||||||
|
{attachment.notes || 'No notes saved with this attachment.'}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
{imageList.length > 0 && (
|
||||||
|
<EntitySection
|
||||||
|
description="Image preview stored for this attachment record."
|
||||||
|
icon={mdiImageOutline}
|
||||||
|
title="Image preview"
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden rounded-[12px] border border-slate-200 bg-slate-50">
|
||||||
|
<img alt={attachment.filename || 'Attachment image'} className="block max-h-[420px] w-full object-contain" src={imageList[0].publicUrl} />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntityAsideCard title="Linked records">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<EntityLinkCard
|
||||||
|
description="Open the parent message this asset belongs to."
|
||||||
|
href={attachment?.message?.id ? `/messages/messages-view/?id=${attachment.message.id}` : undefined}
|
||||||
|
icon={mdiPaperclip}
|
||||||
|
label="Message"
|
||||||
|
value={attachment?.message?.content || 'No message'}
|
||||||
<div className={'mb-4'}>
|
/>
|
||||||
<p className={'block font-bold mb-2'}>Kind</p>
|
</div>
|
||||||
<p>{attachments?.kind ?? 'No data'}</p>
|
</EntityAsideCard>
|
||||||
</div>
|
|
||||||
|
<EntityAsideCard title="Files">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{fileList.length > 0 ? (
|
||||||
|
fileList.map((item: any) => (
|
||||||
|
<a
|
||||||
|
key={item.publicUrl}
|
||||||
|
className="flex items-start justify-between gap-4 rounded-[10px] border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300"
|
||||||
|
href={item.publicUrl}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>File</p>
|
|
||||||
{attachments?.file?.length
|
|
||||||
? dataFormatter.filesFormatter(attachments.file).map(link => (
|
|
||||||
<button
|
|
||||||
key={link.publicUrl}
|
|
||||||
onClick={(e) => saveFile(e, link.publicUrl, link.name)}
|
|
||||||
>
|
>
|
||||||
{link.name}
|
<div className="min-w-0">
|
||||||
</button>
|
<p className="truncate text-[14px] font-medium text-slate-900">{item.name || 'Download file'}</p>
|
||||||
)) : <p>No File</p>
|
<p className="mt-1 text-[12px] leading-5 text-slate-500">Open the stored file in a new tab.</p>
|
||||||
}
|
</div>
|
||||||
</div>
|
<BaseIcon className="shrink-0 text-slate-400" path={mdiOpenInNew} size={16} />
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] leading-6 text-slate-500">No file downloads are attached to this record.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Image</p>
|
|
||||||
{attachments?.image?.length
|
|
||||||
? (
|
|
||||||
<ImageField
|
|
||||||
name={'image'}
|
|
||||||
image={attachments?.image}
|
|
||||||
className='w-20 h-20'
|
|
||||||
/>
|
|
||||||
) : <p>No Image</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Filename</p>
|
|
||||||
<p>{attachments?.filename}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>MIMEtype</p>
|
|
||||||
<p>{attachments?.mime_type}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Sizebytes</p>
|
|
||||||
<p>{attachments?.size_bytes || 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Storagekey</p>
|
|
||||||
<p>{attachments?.storage_key}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Multi Text' hasTextareaHeight>
|
|
||||||
<textarea className={'w-full'} disabled value={attachments?.notes} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
color='info'
|
|
||||||
label='Back'
|
|
||||||
onClick={() => router.push('/attachments/attachments-list')}
|
|
||||||
/>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
AttachmentsView.getLayout = function getLayout(page: ReactElement) {
|
AttachmentsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="READ_ATTACHMENTS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'READ_ATTACHMENTS'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AttachmentsView;
|
export default AttachmentsView;
|
||||||
274
frontend/src/pages/billing.tsx
Normal file
274
frontend/src/pages/billing.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import { mdiArrowLeft, mdiCreditCardOutline } from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import PricingPlanCard from '../components/Billing/PricingPlanCard';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
|
import {
|
||||||
|
BillingPlan,
|
||||||
|
BillingSubscription,
|
||||||
|
formatBillingDate,
|
||||||
|
formatBillingPrice,
|
||||||
|
} from '../lib/billingPlans';
|
||||||
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
|
function getErrorMessage(error: any) {
|
||||||
|
return error?.response?.data || error?.message || 'Something went wrong.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BillingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [plans, setPlans] = useState<BillingPlan[]>([]);
|
||||||
|
const [subscription, setSubscription] = useState<BillingSubscription | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [subscribePlanId, setSubscribePlanId] = useState('');
|
||||||
|
const [canceling, setCanceling] = useState(false);
|
||||||
|
const [confirmingCheckout, setConfirmingCheckout] = useState(false);
|
||||||
|
|
||||||
|
const loadBilling = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [plansResponse, currentResponse] = await Promise.all([
|
||||||
|
axios.get('billing/plans'),
|
||||||
|
axios.get('billing/current'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setPlans(plansResponse.data.plans || []);
|
||||||
|
setSubscription(currentResponse.data.subscription || null);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBilling();
|
||||||
|
}, [loadBilling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutState = router.query.checkout;
|
||||||
|
const sessionId = typeof router.query.session_id === 'string' ? router.query.session_id : '';
|
||||||
|
|
||||||
|
if (checkoutState === 'success' && sessionId) {
|
||||||
|
setConfirmingCheckout(true);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get('billing/checkout-session', {
|
||||||
|
params: {
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
setSubscription(response.data.subscription || null);
|
||||||
|
toast.success('Subscription activated.');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setConfirmingCheckout(false);
|
||||||
|
void router.replace('/billing', undefined, { shallow: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkoutState === 'canceled') {
|
||||||
|
toast.info('Checkout canceled.');
|
||||||
|
void router.replace('/billing', undefined, { shallow: true });
|
||||||
|
}
|
||||||
|
}, [router, router.isReady, router.query.checkout, router.query.session_id]);
|
||||||
|
|
||||||
|
const activePlanId = useMemo(() => {
|
||||||
|
if (!subscription) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status !== 'active') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription.plan?.id || '';
|
||||||
|
}, [subscription]);
|
||||||
|
const canManageStripe = hasPermission(currentUser, 'READ_USERS');
|
||||||
|
|
||||||
|
const handleSubscribe = async (planId: string) => {
|
||||||
|
setSubscribePlanId(planId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('billing/subscribe', { planId });
|
||||||
|
if (!response.data.checkoutUrl) {
|
||||||
|
throw new Error('Stripe checkout URL was not returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.assign(response.data.checkoutUrl);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setSubscribePlanId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setCanceling(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('billing/cancel');
|
||||||
|
setSubscription(response.data.subscription || null);
|
||||||
|
if (response.data.subscription && response.data.subscription.cancelAtPeriodEnd) {
|
||||||
|
toast.success('Subscription will end at the end of the current billing period.');
|
||||||
|
} else {
|
||||||
|
toast.success('Subscription canceled.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setCanceling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Billing')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<div className="flex w-full flex-col gap-6">
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-slate-500"
|
||||||
|
href="/settings"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiArrowLeft} size={18} />
|
||||||
|
Back to settings
|
||||||
|
</Link>
|
||||||
|
{canManageStripe && (
|
||||||
|
<Link
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-[9px] border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700"
|
||||||
|
href="/stripe-settings"
|
||||||
|
>
|
||||||
|
Stripe settings
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Billing
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
Choose a plan and manage your Stripe subscription.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">
|
||||||
|
Pick a plan, jump into Stripe Checkout, and manage renewals from one calm billing screen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Current plan
|
||||||
|
</p>
|
||||||
|
{subscription?.plan ? (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-3 text-[1.75rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
{subscription.plan.name}
|
||||||
|
<span className="ml-3 align-middle text-base font-medium text-slate-400">
|
||||||
|
{subscription.status}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
{formatBillingPrice(subscription.plan.priceMonthly, subscription.plan.currency)} per month
|
||||||
|
{subscription.currentPeriodEnd
|
||||||
|
? ` • ${
|
||||||
|
subscription.cancelAtPeriodEnd
|
||||||
|
? 'Ends'
|
||||||
|
: subscription.status === 'active'
|
||||||
|
? 'Renews'
|
||||||
|
: 'Ended'
|
||||||
|
} ${formatBillingDate(subscription.currentPeriodEnd)}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-1 text-[12px] text-slate-400">
|
||||||
|
<div>Stripe customer: {subscription.stripeCustomerId || 'Not created yet'}</div>
|
||||||
|
<div>Stripe subscription: {subscription.stripeSubscriptionId || 'Not created yet'}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-3 text-[1.75rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
No active subscription
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
Pick one of the three plans below to start a subscription through Stripe Checkout.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subscription?.status === 'active' && (
|
||||||
|
<button
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-[9px] border border-rose-200 bg-rose-50 px-4 text-sm font-medium text-rose-700"
|
||||||
|
disabled={canceling}
|
||||||
|
onClick={() => void handleCancel()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{canceling
|
||||||
|
? 'Canceling…'
|
||||||
|
: subscription.cancelAtPeriodEnd
|
||||||
|
? 'Cancellation scheduled'
|
||||||
|
: 'Cancel subscription'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading || confirmingCheckout ? (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-8">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<BaseIcon path={mdiCreditCardOutline} size={18} />
|
||||||
|
{confirmingCheckout ? 'Confirming checkout…' : 'Loading plans…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-5 xl:grid-cols-3">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const isCurrent = activePlanId === plan.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PricingPlanCard
|
||||||
|
actionDisabled={isCurrent || subscribePlanId === plan.id}
|
||||||
|
actionLabel={isCurrent ? 'Current plan' : activePlanId ? 'Switch plan' : 'Subscribe'}
|
||||||
|
actionLoading={subscribePlanId === plan.id}
|
||||||
|
current={isCurrent}
|
||||||
|
key={plan.id || plan.slug}
|
||||||
|
onAction={() => void handleSubscribe(plan.id || '')}
|
||||||
|
plan={plan}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BillingPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,12 @@
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import PricingPlanCard from '../components/Billing/PricingPlanCard';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
import { marketingPlans } from '../lib/billingPlans';
|
||||||
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
const quickPoints = [
|
const quickPoints = [
|
||||||
'Calm, chat-first workspace',
|
'Calm, chat-first workspace',
|
||||||
@ -27,7 +31,26 @@ const previewThreads = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const previewMetrics = [
|
||||||
|
'3 active agents',
|
||||||
|
'12 recent threads',
|
||||||
|
'Stripe billing ready',
|
||||||
|
];
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
const { currentUser, token } = useAppSelector((state) => state.auth);
|
||||||
|
const [hasSession, setHasSession] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedToken =
|
||||||
|
typeof window === 'undefined' ? '' : window.localStorage.getItem('token') || '';
|
||||||
|
|
||||||
|
setHasSession(Boolean(currentUser?.id || token || storedToken));
|
||||||
|
}, [currentUser?.id, token]);
|
||||||
|
|
||||||
|
const pricingActionHref = hasSession ? '/billing' : '/login';
|
||||||
|
const pricingSectionCtaLabel = hasSession ? 'Open billing' : 'Sign in to subscribe';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -47,13 +70,13 @@ export default function LandingPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-3">
|
<nav className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
className="rounded-full border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur transition hover:bg-white"
|
className="rounded-md border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur"
|
||||||
href="/login"
|
href="/login"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
className="rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
|
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)]"
|
||||||
href="/workspace"
|
href="/workspace"
|
||||||
>
|
>
|
||||||
Open workspace
|
Open workspace
|
||||||
@ -78,13 +101,13 @@ export default function LandingPage() {
|
|||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
className="rounded-full bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
|
className="rounded-md bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)]"
|
||||||
href="/workspace"
|
href="/workspace"
|
||||||
>
|
>
|
||||||
Try the workspace
|
Try the workspace
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
className="rounded-full border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur transition hover:bg-white"
|
className="rounded-md border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur"
|
||||||
href="/login"
|
href="/login"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
@ -94,7 +117,7 @@ export default function LandingPage() {
|
|||||||
<div className="mt-10 grid gap-3 sm:max-w-2xl">
|
<div className="mt-10 grid gap-3 sm:max-w-2xl">
|
||||||
{quickPoints.map((point) => (
|
{quickPoints.map((point) => (
|
||||||
<div
|
<div
|
||||||
className="inline-flex w-fit items-center rounded-full border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
|
className="inline-flex w-fit items-center rounded-md border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
|
||||||
key={point}
|
key={point}
|
||||||
>
|
>
|
||||||
{point}
|
{point}
|
||||||
@ -103,85 +126,107 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="relative lg:pt-2">
|
<section className="relative lg:pt-4">
|
||||||
<div className="absolute inset-4 rounded-[30px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
|
<div className="absolute inset-4 rounded-[16px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
|
||||||
<div className="relative overflow-hidden rounded-[34px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
|
<div className="relative overflow-hidden rounded-[18px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
|
||||||
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4.5">
|
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
|
||||||
Workspace preview
|
Workspace preview
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 max-w-[21rem] text-[1.45rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-sm xl:text-[1.8rem]">
|
<h2 className="mt-2 max-w-[20rem] text-[1.2rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-[23rem] xl:text-[1.45rem]">
|
||||||
History stays visible, but the active conversation keeps the spotlight.
|
History stays visible, while the current ask stays easy to scan.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid min-h-[24rem] lg:grid-cols-[170px_minmax(0,1fr)] xl:min-h-[30rem] xl:grid-cols-[205px_minmax(0,1fr)]">
|
<div className="bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)] px-5 py-4 xl:px-6 xl:py-5">
|
||||||
<div className="border-r border-slate-200/80 bg-[#fbfaf7]/90 p-3 xl:p-4">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<span className="inline-flex items-center rounded-[9px] bg-slate-900 px-3 py-2 text-[12px] font-medium text-white">
|
||||||
className="inline-flex w-full items-center justify-center rounded-2xl bg-slate-900 px-4 py-2.5 text-[13px] font-medium text-white shadow-[0_16px_30px_-22px_rgba(15,23,42,0.6)] xl:text-sm"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
New chat
|
New chat
|
||||||
</button>
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-[9px] border border-slate-200 bg-white px-3 py-2 text-[12px] text-slate-600">
|
||||||
<div className="mt-4 space-y-2.5 xl:space-y-3">
|
Calm workspace
|
||||||
{previewThreads.map((thread) => (
|
</span>
|
||||||
<div
|
<span className="inline-flex items-center rounded-[9px] border border-slate-200 bg-white px-3 py-2 text-[12px] text-slate-600">
|
||||||
className={`rounded-[20px] border px-3 py-3 transition xl:px-3.5 xl:py-3.5 ${
|
Stripe billing ready
|
||||||
thread.active
|
</span>
|
||||||
? 'border-slate-900 bg-slate-900 text-white shadow-[0_18px_40px_-28px_rgba(15,23,42,0.6)]'
|
|
||||||
: 'border-slate-200/90 bg-white/85 text-slate-900'
|
|
||||||
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
|
|
||||||
key={thread.title}
|
|
||||||
>
|
|
||||||
<p className="text-[13px] font-medium xl:text-sm">{thread.title}</p>
|
|
||||||
<p
|
|
||||||
className={`mt-2 text-[11px] leading-5 xl:text-xs xl:leading-6 ${
|
|
||||||
thread.active ? 'text-slate-300' : 'text-slate-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{thread.summary}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)]">
|
<div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,210px)_minmax(0,1fr)]">
|
||||||
<div className="border-b border-slate-200/80 px-4 py-3.5 xl:px-5 xl:py-4">
|
<div className="rounded-[14px] border border-slate-200 bg-white px-3 py-3 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.18)]">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
|
<div className="flex items-center justify-between">
|
||||||
Current conversation
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
|
||||||
</p>
|
Recent threads
|
||||||
<h3 className="mt-2 text-[1.1rem] font-semibold tracking-[-0.05em] text-slate-900 xl:text-[1.5rem]">
|
|
||||||
Draft a launch checklist
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-3 px-4 py-3.5 xl:space-y-4 xl:px-5 xl:py-4.5">
|
|
||||||
<div className="ml-auto max-w-[88%] rounded-[22px] border border-slate-900 bg-slate-900 px-4 py-3.5 text-white shadow-[0_22px_45px_-30px_rgba(15,23,42,0.6)] xl:max-w-[84%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.22em] text-white/55">You</p>
|
|
||||||
<p className="mt-2.5 text-[13px] leading-6 xl:mt-3 xl:text-[14px] xl:leading-7">
|
|
||||||
Build a concise launch checklist for the MVP and include the most
|
|
||||||
important backoffice follow-ups.
|
|
||||||
</p>
|
</p>
|
||||||
|
<span className="text-[11px] text-slate-400">3 active</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-[94%] rounded-[22px] border border-slate-200 bg-white px-4 py-3.5 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.25)] xl:max-w-[92%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
|
<div className="mt-3 space-y-2">
|
||||||
<p className="text-[11px] uppercase tracking-[0.22em] text-slate-400">Assistant</p>
|
{previewThreads.map((thread) => (
|
||||||
<div className="mt-2.5 space-y-2 text-[13px] leading-6 text-slate-600 xl:mt-3 xl:space-y-2.5 xl:text-[14px] xl:leading-7">
|
<div
|
||||||
<p className="font-semibold text-slate-900">Launch checklist</p>
|
className={`rounded-[10px] border px-3 py-2.5 ${
|
||||||
<p>- Validate sign-in, new chat, rename, archive, and delete flows.</p>
|
thread.active
|
||||||
<p>- Confirm agent selection and conversation persistence.</p>
|
? 'border-slate-900 bg-slate-900 text-white'
|
||||||
<p className="hidden xl:block">
|
: 'border-slate-200 bg-slate-50 text-slate-900'
|
||||||
- Review visibility of users, conversations, messages, and usage events.
|
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
|
||||||
</p>
|
key={thread.title}
|
||||||
|
>
|
||||||
|
<p className="text-[12px] font-medium">{thread.title}</p>
|
||||||
|
<p
|
||||||
|
className={`mt-1 line-clamp-2 text-[11px] leading-5 ${
|
||||||
|
thread.active ? 'text-slate-300' : 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{thread.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="rounded-[14px] border border-slate-200 bg-white px-4 py-4 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.18)]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
|
||||||
|
Current conversation
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-[1rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
Draft a launch checklist
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-[9px] border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
|
||||||
|
Code helper
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-2.5">
|
||||||
|
<div className="rounded-[11px] border border-slate-200 bg-slate-50 px-3 py-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] text-slate-400">You</p>
|
||||||
|
<p className="mt-1.5 text-[12px] leading-5 text-slate-700">
|
||||||
|
Build a concise launch checklist for the MVP and include the most
|
||||||
|
important backoffice follow-ups.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[11px] border border-slate-900 bg-slate-900 px-3 py-3 text-white">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] text-white/55">Assistant</p>
|
||||||
|
<p className="mt-1.5 text-[12px] leading-5 text-slate-100">
|
||||||
|
Validate sign-in, new chat, agent selection, and backoffice
|
||||||
|
visibility before launch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-slate-200/80 px-4 py-3 xl:px-5 xl:py-3.5">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<div className="rounded-[20px] border border-white/90 bg-white px-4 py-2.5 text-[12px] text-slate-500 shadow-[0_14px_35px_-28px_rgba(15,23,42,0.18)] xl:rounded-[22px] xl:py-3 xl:text-sm">
|
{previewMetrics.map((metric) => (
|
||||||
Ask for a draft, plan, code snippet, or product spec…
|
<div
|
||||||
|
className="rounded-[10px] border border-slate-200 bg-white px-3 py-2.5 text-[12px] text-slate-600 shadow-[0_14px_35px_-30px_rgba(15,23,42,0.15)]"
|
||||||
|
key={metric}
|
||||||
|
>
|
||||||
|
{metric}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -189,6 +234,51 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-20 rounded-[22px] border border-white/70 bg-white/60 px-6 py-8 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.25)] backdrop-blur lg:px-8 lg:py-9">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Pricing
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-[2rem] font-semibold tracking-[-0.05em] text-slate-900 lg:text-[2.6rem]">
|
||||||
|
Three plans, one calm workspace, and a simple Stripe-backed billing flow.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-600 lg:text-[15px]">
|
||||||
|
Start small, move to Pro when the workspace becomes daily infrastructure, or give a team room to share
|
||||||
|
more agents and more usage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-[9px] border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700"
|
||||||
|
href={pricingActionHref}
|
||||||
|
>
|
||||||
|
{pricingSectionCtaLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-5 xl:grid-cols-3">
|
||||||
|
{marketingPlans.map((plan) => (
|
||||||
|
<PricingPlanCard
|
||||||
|
actionHref={pricingActionHref}
|
||||||
|
actionLabel={
|
||||||
|
hasSession
|
||||||
|
? plan.isFeatured
|
||||||
|
? 'Subscribe in billing'
|
||||||
|
: `Choose ${plan.name}`
|
||||||
|
: 'Sign in to subscribe'
|
||||||
|
}
|
||||||
|
caption={
|
||||||
|
hasSession
|
||||||
|
? 'Subscriptions are completed from the billing screen through Stripe Checkout.'
|
||||||
|
: 'Only signed-in users can start a subscription.'
|
||||||
|
}
|
||||||
|
key={plan.slug}
|
||||||
|
plan={plan}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,184 +1,146 @@
|
|||||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
|
import { mdiArrowLeft, mdiPencilOutline, mdiShieldOutline } from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import Link from 'next/link';
|
||||||
import DatePicker from "react-datepicker";
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
import { Field, Form, Formik } from 'formik';
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox'
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
|
||||||
import SectionMain from '../../components/SectionMain'
|
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
|
||||||
import { getPageTitle } from '../../config'
|
|
||||||
|
|
||||||
import { Field, Form, Formik } from 'formik'
|
|
||||||
import FormField from '../../components/FormField'
|
|
||||||
import BaseDivider from '../../components/BaseDivider'
|
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
|
||||||
import BaseButton from '../../components/BaseButton'
|
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
|
||||||
import { SelectField } from "../../components/SelectField";
|
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/permissions/permissionsSlice'
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
|
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import {
|
||||||
|
actionButtonClassName,
|
||||||
|
EntityAsideCard,
|
||||||
|
EntityIntro,
|
||||||
|
EntitySection,
|
||||||
|
inputClassName,
|
||||||
|
} from '../../components/AdminEntity/PageKit';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch, update } from '../../stores/permissions/permissionsSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
const initialPermissionValues = {
|
||||||
|
name: '',
|
||||||
|
};
|
||||||
|
|
||||||
const EditPermissionsPage = () => {
|
const EditPermissionsPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const initVals = {
|
const { permissions, loading } = useAppSelector((state) => state.permissions);
|
||||||
|
const { id } = router.query;
|
||||||
|
const [initialValues, setInitialValues] = useState(initialPermissionValues);
|
||||||
'name': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
const [initialValues, setInitialValues] = useState(initVals)
|
|
||||||
|
|
||||||
const { permissions } = useAppSelector((state) => state.permissions)
|
|
||||||
|
|
||||||
|
|
||||||
const { id } = router.query
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id: id }))
|
if (typeof id !== 'string') {
|
||||||
}, [id])
|
return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof permissions === 'object') {
|
|
||||||
setInitialValues(permissions)
|
|
||||||
}
|
}
|
||||||
}, [permissions])
|
|
||||||
|
dispatch(fetch({ id }));
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof permissions === 'object') {
|
if (!permissions || Array.isArray(permissions) || typeof permissions !== 'object') {
|
||||||
const newInitialVal = {...initVals};
|
return;
|
||||||
Object.keys(initVals).forEach(el => newInitialVal[el] = (permissions)[el])
|
}
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [permissions])
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
setInitialValues({
|
||||||
await dispatch(update({ id: id, data }))
|
name: permissions.name || '',
|
||||||
await router.push('/permissions/permissions-list')
|
});
|
||||||
}
|
}, [permissions]);
|
||||||
|
|
||||||
|
const handleSubmit = async (data: typeof initialPermissionValues) => {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push(`/permissions/permissions-view/?id=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit permissions')}</title>
|
<title>{getPageTitle('Edit permission')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit permissions'} main>
|
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
||||||
{''}
|
{({ isSubmitting, values }) => {
|
||||||
</SectionTitleLineWithButton>
|
const isPermissionLoading = loading && !initialValues.name;
|
||||||
<CardBox>
|
|
||||||
<Formik
|
|
||||||
enableReinitialize
|
|
||||||
initialValues={initialValues}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<div className="flex w-full flex-col gap-5">
|
||||||
|
<EntityIntro
|
||||||
|
backHref={typeof id === 'string' ? `/permissions/permissions-view/?id=${id}` : '/permissions/permissions-list'}
|
||||||
|
backLabel="Back to permission"
|
||||||
|
description="Rename this access capability without dropping back into the old generator form."
|
||||||
|
kicker="Permission"
|
||||||
|
title="Refine this permission."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntitySection
|
||||||
|
description="Keep the permission key explicit and readable. This name is referenced in access checks."
|
||||||
|
icon={mdiShieldOutline}
|
||||||
|
title="Identity"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="name">
|
||||||
|
Permission name
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="name" name="name" />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<div className="space-y-5">
|
||||||
label="Name"
|
<EntityAsideCard title="Preview">
|
||||||
>
|
<div className="flex items-start gap-3">
|
||||||
<Field
|
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
|
||||||
name="name"
|
<BaseIcon path={mdiPencilOutline} size={20} />
|
||||||
placeholder="Name"
|
</div>
|
||||||
/>
|
<div className="min-w-0">
|
||||||
</FormField>
|
<p className="truncate text-[16px] font-semibold text-slate-900">
|
||||||
|
{values.name || 'Unnamed permission'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">Access capability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
|
||||||
|
<EntityAsideCard title="Actions">
|
||||||
|
{isPermissionLoading && <p className="text-[13px] text-slate-500">Loading permission…</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href={typeof id === 'string' ? `/permissions/permissions-view/?id=${id}` : '/permissions/permissions-list'}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
|
disabled={isSubmitting || isPermissionLoading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/permissions/permissions-list')}/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="UPDATE_PERMISSIONS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'UPDATE_PERMISSIONS'}
|
export default EditPermissionsPage;
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EditPermissionsPage
|
|
||||||
|
|||||||
@ -1,125 +1,139 @@
|
|||||||
|
import { mdiArrowLeft, mdiKeyVariant, mdiPencilOutline, mdiShieldOutline } from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
import React, { ReactElement, useEffect } from 'react';
|
import React, { ReactElement, useEffect } from 'react';
|
||||||
import Head from 'next/head'
|
|
||||||
import DatePicker from "react-datepicker";
|
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import { fetch } from '../../stores/permissions/permissionsSlice'
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
import LayoutAuthenticated from "../../layouts/Authenticated";
|
|
||||||
import {getPageTitle} from "../../config";
|
|
||||||
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
|
||||||
import SectionMain from "../../components/SectionMain";
|
|
||||||
import CardBox from "../../components/CardBox";
|
|
||||||
import BaseButton from "../../components/BaseButton";
|
|
||||||
import BaseDivider from "../../components/BaseDivider";
|
|
||||||
import {mdiChartTimelineVariant} from "@mdi/js";
|
|
||||||
import {SwitchField} from "../../components/SwitchField";
|
|
||||||
import FormField from "../../components/FormField";
|
|
||||||
|
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import {
|
||||||
|
actionButtonClassName,
|
||||||
|
EntityAsideCard,
|
||||||
|
EntityEmptyState,
|
||||||
|
EntityIntro,
|
||||||
|
EntitySection,
|
||||||
|
EntityValueCard,
|
||||||
|
} from '../../components/AdminEntity/PageKit';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch } from '../../stores/permissions/permissionsSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const PermissionsView = () => {
|
const PermissionsView = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const { permissions } = useAppSelector((state) => state.permissions)
|
const { permissions, loading } = useAppSelector((state) => state.permissions);
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const { id } = router.query;
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
function removeLastCharacter(str) {
|
|
||||||
console.log(str,`str`)
|
|
||||||
return str.slice(0, -1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
dispatch(fetch({ id }));
|
||||||
dispatch(fetch({ id }));
|
}, [dispatch, id]);
|
||||||
}, [dispatch, id]);
|
|
||||||
|
|
||||||
|
const permission =
|
||||||
|
!Array.isArray(permissions) && permissions && typeof permissions === 'object' ? permissions : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('View permissions')}</title>
|
<title>{getPageTitle('Permission details')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View permissions')} main>
|
<div className="flex w-full flex-col gap-5">
|
||||||
<BaseButton
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
color='info'
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
label='Edit'
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
|
||||||
|
href="/permissions/permissions-list"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiArrowLeft} size={14} />
|
||||||
|
Back to permissions
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Permission
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
{permission?.name || 'Permission details'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
|
Review the access capability this permission unlocks before wiring it into roles or user-specific
|
||||||
|
overrides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/permissions/permissions-list"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
href={`/permissions/permissions-edit/?id=${id}`}
|
href={`/permissions/permissions-edit/?id=${id}`}
|
||||||
/>
|
>
|
||||||
</SectionTitleLineWithButton>
|
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
|
||||||
<CardBox>
|
Edit permission
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !permission && (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !permission && (
|
||||||
|
<EntityEmptyState
|
||||||
|
description="Try going back to the permissions list and opening this record again."
|
||||||
|
title="We could not load this permission."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
{permission && (
|
||||||
<p className={'block font-bold mb-2'}>Name</p>
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<p>{permissions?.name}</p>
|
<div className="space-y-5">
|
||||||
</div>
|
<EntitySection
|
||||||
|
description="The canonical permission key used across backend checks and admin screens."
|
||||||
|
icon={mdiShieldOutline}
|
||||||
|
title="Identity"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<EntityValueCard label="Name" value={permission.name || 'Untitled permission'} />
|
||||||
|
<EntityValueCard label="Type" value="Access capability" />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntityAsideCard title="At a glance">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-slate-500">
|
||||||
|
<BaseIcon path={mdiKeyVariant} size={16} />
|
||||||
|
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Permission key</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 break-all text-[15px] font-medium text-slate-900">{permission.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
color='info'
|
|
||||||
label='Back'
|
|
||||||
onClick={() => router.push('/permissions/permissions-list')}
|
|
||||||
/>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
PermissionsView.getLayout = function getLayout(page: ReactElement) {
|
PermissionsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="READ_PERMISSIONS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'READ_PERMISSIONS'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PermissionsView;
|
export default PermissionsView;
|
||||||
@ -1,269 +1,166 @@
|
|||||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
|
import { mdiArrowLeft, mdiKeyVariant, mdiPencilOutline, mdiShieldOutline } from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import Link from 'next/link';
|
||||||
import DatePicker from "react-datepicker";
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
import { Field, Form, Formik } from 'formik';
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox'
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
|
||||||
import SectionMain from '../../components/SectionMain'
|
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
|
||||||
import { getPageTitle } from '../../config'
|
|
||||||
|
|
||||||
import { Field, Form, Formik } from 'formik'
|
|
||||||
import FormField from '../../components/FormField'
|
|
||||||
import BaseDivider from '../../components/BaseDivider'
|
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
|
||||||
import BaseButton from '../../components/BaseButton'
|
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
|
||||||
import { SelectField } from "../../components/SelectField";
|
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/roles/rolesSlice'
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
|
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import {
|
||||||
|
actionButtonClassName,
|
||||||
|
EntityAsideCard,
|
||||||
|
EntityIntro,
|
||||||
|
EntitySection,
|
||||||
|
inputClassName,
|
||||||
|
} from '../../components/AdminEntity/PageKit';
|
||||||
|
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch, update } from '../../stores/roles/rolesSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
const initialRoleValues = {
|
||||||
|
name: '',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
const EditRolesPage = () => {
|
const EditRolesPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const initVals = {
|
const { roles, loading } = useAppSelector((state) => state.roles);
|
||||||
|
const { id } = router.query;
|
||||||
|
const [initialValues, setInitialValues] = useState(initialRoleValues);
|
||||||
'name': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
permissions: [],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
const [initialValues, setInitialValues] = useState(initVals)
|
|
||||||
|
|
||||||
const { roles } = useAppSelector((state) => state.roles)
|
|
||||||
|
|
||||||
|
|
||||||
const { id } = router.query
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id: id }))
|
if (typeof id !== 'string') {
|
||||||
}, [id])
|
return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof roles === 'object') {
|
|
||||||
setInitialValues(roles)
|
|
||||||
}
|
}
|
||||||
}, [roles])
|
|
||||||
|
dispatch(fetch({ id }));
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof roles === 'object') {
|
if (!roles || Array.isArray(roles) || typeof roles !== 'object') {
|
||||||
const newInitialVal = {...initVals};
|
return;
|
||||||
Object.keys(initVals).forEach(el => newInitialVal[el] = (roles)[el])
|
}
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [roles])
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
setInitialValues({
|
||||||
await dispatch(update({ id: id, data }))
|
name: roles.name || '',
|
||||||
await router.push('/roles/roles-list')
|
permissions: roles.permissions || [],
|
||||||
}
|
});
|
||||||
|
}, [roles]);
|
||||||
|
|
||||||
|
const handleSubmit = async (data: typeof initialRoleValues) => {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push(`/roles/roles-view/?id=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit roles')}</title>
|
<title>{getPageTitle('Edit role')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit roles'} main>
|
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
||||||
{''}
|
{({ isSubmitting, values }) => {
|
||||||
</SectionTitleLineWithButton>
|
const isRoleLoading = loading && !initialValues.name;
|
||||||
<CardBox>
|
|
||||||
<Formik
|
return (
|
||||||
enableReinitialize
|
<Form>
|
||||||
initialValues={initialValues}
|
<div className="flex w-full flex-col gap-5">
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
<EntityIntro
|
||||||
>
|
backHref={typeof id === 'string' ? `/roles/roles-view/?id=${id}` : '/roles/roles-list'}
|
||||||
<Form>
|
backLabel="Back to role"
|
||||||
|
description="Adjust the label and permission bundle without going through the old admin generator form."
|
||||||
|
kicker="Role"
|
||||||
|
title="Refine this role."
|
||||||
<FormField
|
/>
|
||||||
label="Name"
|
|
||||||
>
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<Field
|
<div className="space-y-5">
|
||||||
name="name"
|
<EntitySection
|
||||||
placeholder="Name"
|
description="Keep the role label human and obvious so workspace access stays easy to reason about."
|
||||||
/>
|
icon={mdiShieldOutline}
|
||||||
</FormField>
|
title="Identity"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="name">
|
||||||
|
Role name
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="name" name="name" />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Pick the capabilities this role should inherit across workspace and admin actions."
|
||||||
|
icon={mdiKeyVariant}
|
||||||
|
title="Permissions"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
component={SelectFieldMany}
|
||||||
|
id="permissions"
|
||||||
|
itemRef="permissions"
|
||||||
|
name="permissions"
|
||||||
|
options={values.permissions}
|
||||||
|
showField="name"
|
||||||
|
/>
|
||||||
|
</EntitySection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntityAsideCard title="Preview">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
|
||||||
|
<BaseIcon path={mdiPencilOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[16px] font-semibold text-slate-900">
|
||||||
|
{values.name || 'Unnamed role'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">
|
||||||
|
{Array.isArray(values.permissions) ? values.permissions.length : 0} permissions selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
|
||||||
|
<EntityAsideCard title="Actions">
|
||||||
|
{isRoleLoading && <p className="text-[13px] text-slate-500">Loading role…</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href={typeof id === 'string' ? `/roles/roles-view/?id=${id}` : '/roles/roles-list'}
|
||||||
|
>
|
||||||
<FormField label='Permissions' labelFor='permissions'>
|
Cancel
|
||||||
<Field
|
</Link>
|
||||||
name='permissions'
|
<button
|
||||||
id='permissions'
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
component={SelectFieldMany}
|
disabled={isSubmitting || isRoleLoading}
|
||||||
options={initialValues.permissions}
|
type="submit"
|
||||||
itemRef={'permissions'}
|
>
|
||||||
|
{isSubmitting ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
showField={'name'}
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/roles/roles-list')}/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
|
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="UPDATE_ROLES">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'UPDATE_ROLES'}
|
export default EditRolesPage;
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EditRolesPage
|
|
||||||
|
|||||||
@ -1,298 +1,185 @@
|
|||||||
|
import {
|
||||||
|
mdiAccountOutline,
|
||||||
|
mdiArrowLeft,
|
||||||
|
mdiKeyVariant,
|
||||||
|
mdiPencilOutline,
|
||||||
|
mdiShieldOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
import React, { ReactElement, useEffect } from 'react';
|
import React, { ReactElement, useEffect } from 'react';
|
||||||
import Head from 'next/head'
|
|
||||||
import DatePicker from "react-datepicker";
|
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import { fetch } from '../../stores/roles/rolesSlice'
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
import LayoutAuthenticated from "../../layouts/Authenticated";
|
|
||||||
import {getPageTitle} from "../../config";
|
|
||||||
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
|
||||||
import SectionMain from "../../components/SectionMain";
|
|
||||||
import CardBox from "../../components/CardBox";
|
|
||||||
import BaseButton from "../../components/BaseButton";
|
|
||||||
import BaseDivider from "../../components/BaseDivider";
|
|
||||||
import {mdiChartTimelineVariant} from "@mdi/js";
|
|
||||||
import {SwitchField} from "../../components/SwitchField";
|
|
||||||
import FormField from "../../components/FormField";
|
|
||||||
|
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import {
|
||||||
|
actionButtonClassName,
|
||||||
|
EntityAsideCard,
|
||||||
|
EntityEmptyState,
|
||||||
|
EntityLinkCard,
|
||||||
|
EntityValueCard,
|
||||||
|
} from '../../components/AdminEntity/PageKit';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch } from '../../stores/roles/rolesSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const RolesView = () => {
|
const RolesView = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const { roles } = useAppSelector((state) => state.roles)
|
const { roles, loading } = useAppSelector((state) => state.roles);
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const { id } = router.query;
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
function removeLastCharacter(str) {
|
|
||||||
console.log(str,`str`)
|
|
||||||
return str.slice(0, -1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
dispatch(fetch({ id }));
|
||||||
dispatch(fetch({ id }));
|
}, [dispatch, id]);
|
||||||
}, [dispatch, id]);
|
|
||||||
|
|
||||||
|
const role = !Array.isArray(roles) && roles && typeof roles === 'object' ? roles : null;
|
||||||
|
const permissions = Array.isArray(role?.permissions) ? role.permissions : [];
|
||||||
|
const users = Array.isArray(role?.users_app_role) ? role.users_app_role : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('View roles')}</title>
|
<title>{getPageTitle('Role details')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View roles')} main>
|
<div className="flex w-full flex-col gap-5">
|
||||||
<BaseButton
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
color='info'
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
label='Edit'
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
|
||||||
|
href="/roles/roles-list"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiArrowLeft} size={14} />
|
||||||
|
Back to roles
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">Role</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
{role?.name || 'Role details'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
|
Review who inherits this role and which capabilities are bundled into it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`} href="/roles/roles-list">
|
||||||
|
Close
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
href={`/roles/roles-edit/?id=${id}`}
|
href={`/roles/roles-edit/?id=${id}`}
|
||||||
/>
|
>
|
||||||
</SectionTitleLineWithButton>
|
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
|
||||||
<CardBox>
|
Edit role
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !role && (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !role && (
|
||||||
|
<EntityEmptyState
|
||||||
|
description="Try going back to the roles list and opening this record again."
|
||||||
|
title="We could not load this role."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
{role && (
|
||||||
<p className={'block font-bold mb-2'}>Name</p>
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<p>{roles?.name}</p>
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiShieldOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">Identity</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
The role label people inherit when they are assigned this access bundle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<EntityValueCard label="Role name" value={role.name || 'Untitled role'} />
|
||||||
|
<EntityValueCard label="Permission count" value={permissions.length.toString()} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiKeyVariant} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">Permissions</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Capabilities this role grants across the workspace and admin surface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{permissions.length > 0 ? (
|
||||||
|
permissions.map((item: any) => (
|
||||||
|
<EntityLinkCard
|
||||||
|
key={item.id}
|
||||||
|
description="Open the permission record for more detail."
|
||||||
|
href={`/permissions/permissions-view/?id=${item.id}`}
|
||||||
|
icon={mdiKeyVariant}
|
||||||
|
label="Permission"
|
||||||
|
value={item.name || 'Unnamed permission'}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] leading-6 text-slate-500">No permissions linked to this role yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntityAsideCard title="Assigned users">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{users.length > 0 ? (
|
||||||
|
users.map((item: any) => (
|
||||||
|
<EntityLinkCard
|
||||||
|
key={item.id}
|
||||||
|
description={item.email || 'Open this user record.'}
|
||||||
|
href={`/users/users-view/?id=${item.id}`}
|
||||||
|
icon={mdiAccountOutline}
|
||||||
|
label="User"
|
||||||
|
value={[item.firstName, item.lastName].filter(Boolean).join(' ') || item.email || 'Unnamed user'}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
<>
|
<p className="text-[13px] leading-6 text-slate-500">No users currently inherit this role.</p>
|
||||||
<p className={'block font-bold mb-2'}>Permissions</p>
|
)}
|
||||||
<CardBox
|
</div>
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
</EntityAsideCard>
|
||||||
hasTable
|
</div>
|
||||||
>
|
</div>
|
||||||
<div className='overflow-x-auto'>
|
)}
|
||||||
<table>
|
</div>
|
||||||
<thead>
|
</SectionMain>
|
||||||
<tr>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Name</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{roles.permissions && Array.isArray(roles.permissions) &&
|
|
||||||
roles.permissions.map((item: any) => (
|
|
||||||
<tr key={item.id} onClick={() => router.push(`/permissions/permissions-view/?id=${item.id}`)}>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="name">
|
|
||||||
{ item.name }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{!roles?.permissions?.length && <div className={'text-center py-4'}>No data</div>}
|
|
||||||
</CardBox>
|
|
||||||
</>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<>
|
|
||||||
<p className={'block font-bold mb-2'}>Users App Role</p>
|
|
||||||
<CardBox
|
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
|
||||||
hasTable
|
|
||||||
>
|
|
||||||
<div className='overflow-x-auto'>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
|
|
||||||
<th>First Name</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Last Name</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Phone Number</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>E-Mail</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Disabled</th>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{roles.users_app_role && Array.isArray(roles.users_app_role) &&
|
|
||||||
roles.users_app_role.map((item: any) => (
|
|
||||||
<tr key={item.id} onClick={() => router.push(`/users/users-view/?id=${item.id}`)}>
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="firstName">
|
|
||||||
{ item.firstName }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="lastName">
|
|
||||||
{ item.lastName }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="phoneNumber">
|
|
||||||
{ item.phoneNumber }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="email">
|
|
||||||
{ item.email }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<td data-label="disabled">
|
|
||||||
{ dataFormatter.booleanFormatter(item.disabled) }
|
|
||||||
</td>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{!roles?.users_app_role?.length && <div className={'text-center py-4'}>No data</div>}
|
|
||||||
</CardBox>
|
|
||||||
</>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
color='info'
|
|
||||||
label='Back'
|
|
||||||
onClick={() => router.push('/roles/roles-list')}
|
|
||||||
/>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
RolesView.getLayout = function getLayout(page: ReactElement) {
|
RolesView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="READ_ROLES">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'READ_ROLES'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RolesView;
|
export default RolesView;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
mdiAccountCircleOutline,
|
mdiAccountCircleOutline,
|
||||||
|
mdiCreditCardOutline,
|
||||||
mdiCogOutline,
|
mdiCogOutline,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiFileCodeOutline,
|
mdiFileCodeOutline,
|
||||||
@ -20,6 +21,7 @@ function SettingsPage() {
|
|||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
const canAccessAdmin = hasPermission(currentUser, ADMIN_ENTRY_PERMISSIONS);
|
const canAccessAdmin = hasPermission(currentUser, ADMIN_ENTRY_PERMISSIONS);
|
||||||
const canAccessApiDocs = hasPermission(currentUser, 'READ_API_DOCS');
|
const canAccessApiDocs = hasPermission(currentUser, 'READ_API_DOCS');
|
||||||
|
const canManageStripe = hasPermission(currentUser, 'READ_USERS');
|
||||||
const apiDocsHref = process.env.NEXT_PUBLIC_BACK_API
|
const apiDocsHref = process.env.NEXT_PUBLIC_BACK_API
|
||||||
? `${process.env.NEXT_PUBLIC_BACK_API.replace(/\/api$/, '')}/api-docs/`
|
? `${process.env.NEXT_PUBLIC_BACK_API.replace(/\/api$/, '')}/api-docs/`
|
||||||
: '/api-docs/';
|
: '/api-docs/';
|
||||||
@ -68,6 +70,50 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className="group rounded-[12px] border border-slate-200 bg-white px-5 py-5"
|
||||||
|
href="/billing"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiCreditCardOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-medium text-slate-900">Billing</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
Manage your plan, Stripe subscription, and renewal state.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-[12px] text-slate-400">Plans, renewals, and cancellations</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-slate-400">
|
||||||
|
<BaseIcon path={mdiChevronRight} size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{canManageStripe && (
|
||||||
|
<Link
|
||||||
|
className="group rounded-[12px] border border-slate-200 bg-white px-5 py-5"
|
||||||
|
href="/stripe-settings"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
<BaseIcon path={mdiCreditCardOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-medium text-slate-900">Stripe</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
Save Stripe keys, configure the webhook endpoint, and map live product and price IDs.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-[12px] text-slate-400">Secrets, webhooks, and plan mapping</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-slate-400">
|
||||||
|
<BaseIcon path={mdiChevronRight} size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{canAccessAdmin && (
|
{canAccessAdmin && (
|
||||||
<Link
|
<Link
|
||||||
className="group rounded-[12px] border border-slate-200 bg-white px-5 py-5"
|
className="group rounded-[12px] border border-slate-200 bg-white px-5 py-5"
|
||||||
|
|||||||
306
frontend/src/pages/stripe-settings.tsx
Normal file
306
frontend/src/pages/stripe-settings.tsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import {
|
||||||
|
mdiCreditCardOutline,
|
||||||
|
mdiFileCodeOutline,
|
||||||
|
mdiKeyVariant,
|
||||||
|
mdiShieldOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import {
|
||||||
|
actionButtonClassName,
|
||||||
|
EntityAsideCard,
|
||||||
|
EntityIntro,
|
||||||
|
EntitySection,
|
||||||
|
EntityValueCard,
|
||||||
|
inputClassName,
|
||||||
|
} from '../components/AdminEntity/PageKit';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import { StripeSettingsState } from '../lib/billingPlans';
|
||||||
|
|
||||||
|
const initialValues: StripeSettingsState = {
|
||||||
|
plans: [],
|
||||||
|
stripeSecretKey: '',
|
||||||
|
stripeSecretKeyPreview: '',
|
||||||
|
stripeSecretKeySource: 'missing',
|
||||||
|
stripeWebhookSecret: '',
|
||||||
|
stripeWebhookSecretPreview: '',
|
||||||
|
stripeWebhookSecretSource: 'missing',
|
||||||
|
webhookEndpoint: '',
|
||||||
|
webhookEvents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: any) {
|
||||||
|
return error?.response?.data || error?.message || 'Something went wrong.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSourceLabel(source: string) {
|
||||||
|
if (source === 'saved') {
|
||||||
|
return 'Saved in workspace';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'env') {
|
||||||
|
return 'Runtime env fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StripeSettingsPage() {
|
||||||
|
const [settings, setSettings] = useState(initialValues);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('billing/stripe-settings');
|
||||||
|
setSettings(response.data.settings || initialValues);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: StripeSettingsState) => {
|
||||||
|
const payload = {
|
||||||
|
plans: values.plans.map((plan) => ({
|
||||||
|
id: plan.id,
|
||||||
|
stripePriceId: plan.stripePriceId || '',
|
||||||
|
stripeProductId: plan.stripeProductId || '',
|
||||||
|
})),
|
||||||
|
stripeSecretKey: values.stripeSecretKey || '',
|
||||||
|
stripeWebhookSecret: values.stripeWebhookSecret || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.put('billing/stripe-settings', {
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSettings(response.data.settings || initialValues);
|
||||||
|
toast.success('Stripe settings updated.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Stripe settings')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<Formik enableReinitialize initialValues={settings} onSubmit={(values) => void handleSubmit(values)}>
|
||||||
|
{({ isSubmitting, values }) => (
|
||||||
|
<Form>
|
||||||
|
<div className="flex w-full flex-col gap-5">
|
||||||
|
<EntityIntro
|
||||||
|
backHref="/settings"
|
||||||
|
backLabel="Back to settings"
|
||||||
|
description="Configure Stripe secrets, wire the billing webhook, and replace mock price IDs with real Stripe prices for each plan."
|
||||||
|
kicker="Stripe"
|
||||||
|
title="Connect the workspace to Stripe."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntitySection
|
||||||
|
description="Save the Stripe secret key and webhook secret that billing should use for Checkout, subscriptions, and webhook verification."
|
||||||
|
icon={mdiKeyVariant}
|
||||||
|
title="Runtime keys"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="stripeSecretKey">
|
||||||
|
STRIPE_SECRET_KEY
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id="stripeSecretKey"
|
||||||
|
name="stripeSecretKey"
|
||||||
|
placeholder="sk_live_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="stripeWebhookSecret">
|
||||||
|
STRIPE_WEBHOOK_SECRET
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id="stripeWebhookSecret"
|
||||||
|
name="stripeWebhookSecret"
|
||||||
|
placeholder="whsec_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Register this endpoint in Stripe and subscribe it to the exact events the backend already handles."
|
||||||
|
icon={mdiFileCodeOutline}
|
||||||
|
title="Webhook"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<EntityValueCard
|
||||||
|
label="Webhook endpoint"
|
||||||
|
value={
|
||||||
|
<code className="break-all rounded-[8px] bg-slate-50 px-2 py-1 text-[13px] text-slate-700">
|
||||||
|
{values.webhookEndpoint || '/api/billing/webhook'}
|
||||||
|
</code>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-4">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Events to register
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{values.webhookEvents.map((eventName) => (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-[999px] border border-slate-200 bg-slate-50 px-2.5 py-1 text-[12px] text-slate-700"
|
||||||
|
key={eventName}
|
||||||
|
>
|
||||||
|
{eventName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Replace the mock Stripe IDs with the real Product and Price IDs created in your Stripe dashboard."
|
||||||
|
icon={mdiCreditCardOutline}
|
||||||
|
title="Plan mapping"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{values.plans.map((plan, index) => (
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-4" key={plan.id || plan.slug || index}>
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-slate-900">{plan.name}</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">{plan.slug}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="mb-2 block text-[13px] font-medium text-slate-700"
|
||||||
|
htmlFor={`plans.${index}.stripeProductId`}
|
||||||
|
>
|
||||||
|
Stripe product ID
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id={`plans.${index}.stripeProductId`}
|
||||||
|
name={`plans.${index}.stripeProductId`}
|
||||||
|
placeholder="prod_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="mb-2 block text-[13px] font-medium text-slate-700"
|
||||||
|
htmlFor={`plans.${index}.stripePriceId`}
|
||||||
|
>
|
||||||
|
Stripe price ID
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
className={inputClassName}
|
||||||
|
id={`plans.${index}.stripePriceId`}
|
||||||
|
name={`plans.${index}.stripePriceId`}
|
||||||
|
placeholder="price_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntityAsideCard title="Current state">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<EntityValueCard
|
||||||
|
label="Secret key source"
|
||||||
|
value={formatSourceLabel(values.stripeSecretKeySource)}
|
||||||
|
/>
|
||||||
|
<EntityValueCard
|
||||||
|
label="Webhook secret source"
|
||||||
|
value={formatSourceLabel(values.stripeWebhookSecretSource)}
|
||||||
|
/>
|
||||||
|
<EntityValueCard
|
||||||
|
label="Secret key preview"
|
||||||
|
value={values.stripeSecretKeyPreview || 'Not configured'}
|
||||||
|
/>
|
||||||
|
<EntityValueCard
|
||||||
|
label="Webhook secret preview"
|
||||||
|
value={values.stripeWebhookSecretPreview || 'Not configured'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
|
||||||
|
<EntityAsideCard title="What this enables">
|
||||||
|
<div className="space-y-3 text-[13px] leading-6 text-slate-500">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<BaseIcon path={mdiShieldOutline} size={16} />
|
||||||
|
<p>Checkout runs through real Stripe Checkout instead of mock subscribe flows.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<BaseIcon path={mdiShieldOutline} size={16} />
|
||||||
|
<p>Cancellation uses Stripe <code>cancel_at_period_end</code> instead of a local fake state.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<BaseIcon path={mdiShieldOutline} size={16} />
|
||||||
|
<p>Webhook sync keeps local subscriptions updated from Stripe events.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
|
||||||
|
<EntityAsideCard title="Actions">
|
||||||
|
{loading && <p className="text-[13px] text-slate-500">Loading Stripe settings…</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/settings"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving…' : 'Save Stripe settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StripeSettingsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated permission="READ_USERS">{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StripeSettingsPage;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,596 +1,212 @@
|
|||||||
|
import {
|
||||||
|
mdiAccountOutline,
|
||||||
|
mdiArrowLeft,
|
||||||
|
mdiClockOutline,
|
||||||
|
mdiCurrencyUsd,
|
||||||
|
mdiMessageTextOutline,
|
||||||
|
mdiOpenInNew,
|
||||||
|
mdiPencilOutline,
|
||||||
|
mdiRobotOutline,
|
||||||
|
mdiTimelineTextOutline,
|
||||||
|
mdiWrenchOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
import React, { ReactElement, useEffect } from 'react';
|
import React, { ReactElement, useEffect } from 'react';
|
||||||
import Head from 'next/head'
|
|
||||||
import DatePicker from "react-datepicker";
|
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import { fetch } from '../../stores/usage_events/usage_eventsSlice'
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
import LayoutAuthenticated from "../../layouts/Authenticated";
|
|
||||||
import {getPageTitle} from "../../config";
|
|
||||||
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
|
||||||
import SectionMain from "../../components/SectionMain";
|
|
||||||
import CardBox from "../../components/CardBox";
|
|
||||||
import BaseButton from "../../components/BaseButton";
|
|
||||||
import BaseDivider from "../../components/BaseDivider";
|
|
||||||
import {mdiChartTimelineVariant} from "@mdi/js";
|
|
||||||
import {SwitchField} from "../../components/SwitchField";
|
|
||||||
import FormField from "../../components/FormField";
|
|
||||||
|
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import {
|
||||||
|
actionButtonClassName,
|
||||||
|
EntityAsideCard,
|
||||||
|
EntityEmptyState,
|
||||||
|
EntityLinkCard,
|
||||||
|
EntitySection,
|
||||||
|
EntityValueCard,
|
||||||
|
formatDateTime,
|
||||||
|
formatMoney,
|
||||||
|
formatName,
|
||||||
|
formatRole,
|
||||||
|
formatTokens,
|
||||||
|
} from '../../components/AdminEntity/PageKit';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch } from '../../stores/usage_events/usage_eventsSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const Usage_eventsView = () => {
|
const Usage_eventsView = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const { usage_events } = useAppSelector((state) => state.usage_events)
|
const { usage_events, loading } = useAppSelector((state) => state.usage_events);
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const { id } = router.query;
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
function removeLastCharacter(str) {
|
|
||||||
console.log(str,`str`)
|
|
||||||
return str.slice(0, -1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
dispatch(fetch({ id }));
|
||||||
dispatch(fetch({ id }));
|
}, [dispatch, id]);
|
||||||
}, [dispatch, id]);
|
|
||||||
|
|
||||||
|
const usageEvent =
|
||||||
|
!Array.isArray(usage_events) && usage_events && typeof usage_events === 'object' ? usage_events : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('View usage_events')}</title>
|
<title>{getPageTitle('Usage event details')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View usage_events')} main>
|
<div className="flex w-full flex-col gap-5">
|
||||||
<BaseButton
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
|
||||||
color='info'
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
label='Edit'
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
|
||||||
|
href="/usage_events/usage_events-list"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiArrowLeft} size={14} />
|
||||||
|
Back to usage events
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
|
||||||
|
Usage event
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
|
||||||
|
{formatRole(usageEvent?.event_type)} event
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">
|
||||||
|
Inspect token counts, model/provider metadata, and the linked records that produced this activity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/usage_events/usage_events-list"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
href={`/usage_events/usage_events-edit/?id=${id}`}
|
href={`/usage_events/usage_events-edit/?id=${id}`}
|
||||||
/>
|
>
|
||||||
</SectionTitleLineWithButton>
|
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
|
||||||
<CardBox>
|
Edit usage event
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !usageEvent && (
|
||||||
|
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !usageEvent && (
|
||||||
|
<EntityEmptyState
|
||||||
|
description="Try going back to the usage events list and opening this record again."
|
||||||
|
title="We could not load this usage event."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{usageEvent && (
|
||||||
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntitySection
|
||||||
<div className={'mb-4'}>
|
description="Core counters and provider details recorded for this activity entry."
|
||||||
<p className={'block font-bold mb-2'}>User</p>
|
icon={mdiTimelineTextOutline}
|
||||||
|
title="Overview"
|
||||||
|
>
|
||||||
<p>{usage_events?.user?.firstName ?? 'No data'}</p>
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<EntityValueCard label="Event type" value={formatRole(usageEvent.event_type)} />
|
||||||
|
<EntityValueCard label="Occurred" value={formatDateTime(usageEvent.occurred_at)} />
|
||||||
|
<EntityValueCard label="Provider" value={usageEvent.provider || 'No provider'} />
|
||||||
|
<EntityValueCard label="Model" value={usageEvent.model || 'No model'} />
|
||||||
|
<EntityValueCard label="Input tokens" value={formatTokens(usageEvent.input_tokens)} />
|
||||||
|
<EntityValueCard label="Output tokens" value={formatTokens(usageEvent.output_tokens)} />
|
||||||
|
<EntityValueCard label="Total tokens" value={formatTokens(usageEvent.total_tokens)} />
|
||||||
|
<EntityValueCard label="Cost" value={formatMoney(usageEvent.cost_usd)} />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Raw structured metadata captured with the usage entry."
|
||||||
|
icon={mdiWrenchOutline}
|
||||||
|
title="Metadata"
|
||||||
|
>
|
||||||
</div>
|
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4">
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-[13px] leading-6 text-slate-700">
|
||||||
|
{usageEvent.metadata_json || 'No metadata saved.'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<EntityAsideCard title="Linked records">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<EntityLinkCard
|
||||||
|
description={usageEvent?.user?.email || 'Event owner'}
|
||||||
|
href={usageEvent?.user?.id ? `/users/users-view/?id=${usageEvent.user.id}` : undefined}
|
||||||
|
icon={mdiAccountOutline}
|
||||||
|
label="User"
|
||||||
|
value={formatName(usageEvent?.user)}
|
||||||
|
/>
|
||||||
|
<EntityLinkCard
|
||||||
|
description={usageEvent?.conversation?.status ? `${usageEvent.conversation.status} conversation` : 'Parent conversation'}
|
||||||
|
href={usageEvent?.conversation?.id ? `/conversations/conversations-view/?id=${usageEvent.conversation.id}` : undefined}
|
||||||
|
icon={mdiMessageTextOutline}
|
||||||
|
label="Conversation"
|
||||||
|
value={usageEvent?.conversation?.title || 'No conversation'}
|
||||||
|
/>
|
||||||
|
<EntityLinkCard
|
||||||
|
description="Source message"
|
||||||
|
href={usageEvent?.message?.id ? `/messages/messages-view/?id=${usageEvent.message.id}` : undefined}
|
||||||
|
icon={mdiOpenInNew}
|
||||||
|
label="Message"
|
||||||
|
value={usageEvent?.message?.content || 'No message'}
|
||||||
<div className={'mb-4'}>
|
/>
|
||||||
<p className={'block font-bold mb-2'}>Conversation</p>
|
<EntityLinkCard
|
||||||
|
description="Assigned agent"
|
||||||
|
href={usageEvent?.agent?.id ? `/agents/agents-view/?id=${usageEvent.agent.id}` : undefined}
|
||||||
|
icon={mdiRobotOutline}
|
||||||
|
label="Agent"
|
||||||
|
value={usageEvent?.agent?.name || 'No agent'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
|
||||||
|
<EntityAsideCard title="At a glance">
|
||||||
<p>{usage_events?.conversation?.title ?? 'No data'}</p>
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-slate-500">
|
||||||
|
<BaseIcon path={mdiClockOutline} size={16} />
|
||||||
|
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Occurred</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[15px] font-medium text-slate-900">{formatDateTime(usageEvent.occurred_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
</div>
|
<div className="flex items-center gap-2 text-slate-500">
|
||||||
|
<BaseIcon path={mdiCurrencyUsd} size={16} />
|
||||||
|
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Cost</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[15px] font-medium text-slate-900">{formatMoney(usageEvent.cost_usd)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Message</p>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<p>{usage_events?.message?.content ?? 'No data'}</p>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Agent</p>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<p>{usage_events?.agent?.name ?? 'No data'}</p>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Eventtype</p>
|
|
||||||
<p>{usage_events?.event_type ?? 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Occurredat'>
|
|
||||||
{usage_events.occurred_at ? <DatePicker
|
|
||||||
dateFormat="yyyy-MM-dd hh:mm"
|
|
||||||
showTimeSelect
|
|
||||||
selected={usage_events.occurred_at ?
|
|
||||||
new Date(
|
|
||||||
dayjs(usage_events.occurred_at).format('YYYY-MM-DD hh:mm'),
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
disabled
|
|
||||||
/> : <p>No Occurredat</p>}
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Inputtokens</p>
|
|
||||||
<p>{usage_events?.input_tokens || 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Outputtokens</p>
|
|
||||||
<p>{usage_events?.output_tokens || 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Totaltokens</p>
|
|
||||||
<p>{usage_events?.total_tokens || 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>CostUSD</p>
|
|
||||||
<p>{usage_events?.cost_usd || 'No data'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Provider</p>
|
|
||||||
<p>{usage_events?.provider}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
|
||||||
<p className={'block font-bold mb-2'}>Model</p>
|
|
||||||
<p>{usage_events?.model}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Multi Text' hasTextareaHeight>
|
|
||||||
<textarea className={'w-full'} disabled value={usage_events?.metadata_json} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
color='info'
|
|
||||||
label='Back'
|
|
||||||
onClick={() => router.push('/usage_events/usage_events-list')}
|
|
||||||
/>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Usage_eventsView.getLayout = function getLayout(page: ReactElement) {
|
Usage_eventsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="READ_USAGE_EVENTS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'READ_USAGE_EVENTS'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Usage_eventsView;
|
export default Usage_eventsView;
|
||||||
@ -1,699 +1,304 @@
|
|||||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
|
import {
|
||||||
import Head from 'next/head'
|
mdiAccountOutline,
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
mdiArrowLeft,
|
||||||
import DatePicker from "react-datepicker";
|
mdiKeyVariant,
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
mdiPencilOutline,
|
||||||
import dayjs from "dayjs";
|
mdiShieldOutline,
|
||||||
|
mdiUpload,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox'
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import {
|
||||||
import SectionMain from '../../components/SectionMain'
|
actionButtonClassName,
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
EntityAsideCard,
|
||||||
import { getPageTitle } from '../../config'
|
EntityIntro,
|
||||||
|
EntitySection,
|
||||||
|
formatName,
|
||||||
|
inputClassName,
|
||||||
|
} from '../../components/AdminEntity/PageKit';
|
||||||
|
import FormImagePicker from '../../components/FormImagePicker';
|
||||||
|
import { SelectField } from '../../components/SelectField';
|
||||||
|
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||||
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { fetch, update } from '../../stores/users/usersSlice';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
import { Field, Form, Formik } from 'formik'
|
const initialUserValues = {
|
||||||
import FormField from '../../components/FormField'
|
app_role: null,
|
||||||
import BaseDivider from '../../components/BaseDivider'
|
avatar: [],
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
custom_permissions: [],
|
||||||
import BaseButton from '../../components/BaseButton'
|
disabled: false,
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
email: '',
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
firstName: '',
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
lastName: '',
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
password: '',
|
||||||
import { SelectField } from "../../components/SelectField";
|
phoneNumber: '',
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
};
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/users/usersSlice'
|
function normalizeUserValues(user: any) {
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
return {
|
||||||
import { useRouter } from 'next/router'
|
app_role: user?.app_role || null,
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
avatar: user?.avatar || [],
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
custom_permissions: user?.custom_permissions || [],
|
||||||
import ImageField from "../../components/ImageField";
|
disabled: Boolean(user?.disabled),
|
||||||
|
email: user?.email || '',
|
||||||
|
firstName: user?.firstName || '',
|
||||||
|
lastName: user?.lastName || '',
|
||||||
|
password: '',
|
||||||
|
phoneNumber: user?.phoneNumber || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarUrl(values: typeof initialUserValues) {
|
||||||
|
if (!Array.isArray(values.avatar) || values.avatar.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.avatar[0]?.publicUrl || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(values: typeof initialUserValues) {
|
||||||
|
const fullName = [values.firstName, values.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
|
if (!fullName) {
|
||||||
|
return 'U';
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullName
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() || '')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
const EditUsersPage = () => {
|
const EditUsersPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const initVals = {
|
const { users, loading } = useAppSelector((state) => state.users);
|
||||||
|
const { id } = router.query;
|
||||||
|
const [initialValues, setInitialValues] = useState(initialUserValues);
|
||||||
'firstName': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'lastName': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'phoneNumber': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'email': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
disabled: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
avatar: [],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app_role: null,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
custom_permissions: [],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
password: ''
|
|
||||||
|
|
||||||
}
|
|
||||||
const [initialValues, setInitialValues] = useState(initVals)
|
|
||||||
|
|
||||||
const { users } = useAppSelector((state) => state.users)
|
|
||||||
|
|
||||||
|
|
||||||
const { id } = router.query
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id: id }))
|
if (typeof id !== 'string') {
|
||||||
}, [id])
|
return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof users === 'object') {
|
|
||||||
setInitialValues(users)
|
|
||||||
}
|
}
|
||||||
}, [users])
|
|
||||||
|
dispatch(fetch({ id }));
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof users === 'object') {
|
if (!users || Array.isArray(users) || typeof users !== 'object') {
|
||||||
const newInitialVal = {...initVals};
|
return;
|
||||||
Object.keys(initVals).forEach(el => newInitialVal[el] = (users)[el])
|
}
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [users])
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
setInitialValues(normalizeUserValues(users));
|
||||||
await dispatch(update({ id: id, data }))
|
}, [users]);
|
||||||
await router.push('/users/users-list')
|
|
||||||
}
|
const handleSubmit = async (data: typeof initialUserValues) => {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push(`/users/users-view/?id=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit users')}</title>
|
<title>{getPageTitle('Edit user')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit users'} main>
|
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
||||||
{''}
|
{({ isSubmitting, values }) => {
|
||||||
</SectionTitleLineWithButton>
|
const avatarUrl = getAvatarUrl(values);
|
||||||
<CardBox>
|
const initials = getInitials(values);
|
||||||
<Formik
|
const isUserLoading = loading && !initialValues.email && !initialValues.firstName;
|
||||||
enableReinitialize
|
|
||||||
initialValues={initialValues}
|
return (
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
<Form>
|
||||||
>
|
<div className="flex w-full flex-col gap-5">
|
||||||
<Form>
|
<EntityIntro
|
||||||
|
backHref={typeof id === 'string' ? `/users/users-view/?id=${id}` : '/users/users-list'}
|
||||||
|
backLabel="Back to user"
|
||||||
|
description="Update identity, avatar, role assignment, and custom access overrides in one clean editing surface."
|
||||||
<FormField
|
kicker="User"
|
||||||
label="First Name"
|
title="Refine this user."
|
||||||
>
|
/>
|
||||||
<Field
|
|
||||||
name="firstName"
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
placeholder="First Name"
|
<div className="space-y-5">
|
||||||
/>
|
<EntitySection
|
||||||
</FormField>
|
description="Basic identity fields people will see across the workspace."
|
||||||
|
icon={mdiAccountOutline}
|
||||||
|
title="Identity"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="firstName">
|
||||||
|
First name
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="firstName" name="firstName" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="lastName">
|
||||||
|
Last name
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="lastName" name="lastName" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="phoneNumber">
|
||||||
|
Phone number
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="phoneNumber" name="phoneNumber" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="email" name="email" type="email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<FormField
|
</EntitySection>
|
||||||
label="Last Name"
|
|
||||||
>
|
<EntitySection
|
||||||
<Field
|
description="Keep the avatar current so the workspace and navbar reflect the right profile image."
|
||||||
name="lastName"
|
icon={mdiUpload}
|
||||||
placeholder="Last Name"
|
title="Avatar"
|
||||||
/>
|
>
|
||||||
</FormField>
|
<Field
|
||||||
|
color="info"
|
||||||
|
component={FormImagePicker}
|
||||||
|
icon={mdiUpload}
|
||||||
|
id="avatar"
|
||||||
|
label="Avatar"
|
||||||
|
name="avatar"
|
||||||
|
path="users/avatar"
|
||||||
|
schema={{ formats: undefined, size: undefined }}
|
||||||
|
/>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Control account status, role assignment, and any custom permission overrides."
|
||||||
|
icon={mdiShieldOutline}
|
||||||
|
title="Access"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<label className="mb-3 block text-[13px] font-medium text-slate-700" htmlFor="disabled">
|
||||||
|
Disabled
|
||||||
|
</label>
|
||||||
|
<Field component={SwitchField} id="disabled" name="disabled" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="app_role">
|
||||||
|
App role
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
component={SelectField}
|
||||||
<FormField
|
id="app_role"
|
||||||
label="Phone Number"
|
itemRef="roles"
|
||||||
>
|
name="app_role"
|
||||||
<Field
|
options={values.app_role}
|
||||||
name="phoneNumber"
|
showField="name"
|
||||||
placeholder="Phone Number"
|
/>
|
||||||
/>
|
</div>
|
||||||
</FormField>
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="custom_permissions">
|
||||||
|
Custom permissions
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
component={SelectFieldMany}
|
||||||
|
id="custom_permissions"
|
||||||
|
itemRef="permissions"
|
||||||
|
name="custom_permissions"
|
||||||
|
options={values.custom_permissions}
|
||||||
|
showField="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Set a new password only when you intentionally want to rotate credentials."
|
||||||
|
icon={mdiKeyVariant}
|
||||||
|
title="Security"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="password">
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="password" name="password" type="password" />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<div className="space-y-5">
|
||||||
label="E-Mail"
|
<EntityAsideCard title="Preview">
|
||||||
>
|
<div className="flex items-start gap-3">
|
||||||
<Field
|
{avatarUrl ? (
|
||||||
name="email"
|
<img
|
||||||
placeholder="E-Mail"
|
alt={formatName(values)}
|
||||||
/>
|
className="h-14 w-14 rounded-[12px] border border-slate-200 object-cover"
|
||||||
</FormField>
|
src={avatarUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex h-14 w-14 items-center justify-center rounded-[12px] border border-slate-200 bg-slate-50 text-[18px] font-semibold text-slate-700">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[16px] font-semibold text-slate-900">
|
||||||
|
{formatName(values)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">{values.email || 'No email set'}</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">
|
||||||
|
{values.app_role?.name || 'No role'} · {values.disabled ? 'Disabled' : 'Active'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
|
||||||
|
<EntityAsideCard title="Actions">
|
||||||
|
{isUserLoading && <p className="text-[13px] text-slate-500">Loading user…</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href={typeof id === 'string' ? `/users/users-view/?id=${id}` : '/users/users-list'}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
|
disabled={isSubmitting || isUserLoading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Disabled' labelFor='disabled'>
|
|
||||||
<Field
|
|
||||||
name='disabled'
|
|
||||||
id='disabled'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField>
|
|
||||||
<Field
|
|
||||||
label='Avatar'
|
|
||||||
color='info'
|
|
||||||
icon={mdiUpload}
|
|
||||||
path={'users/avatar'}
|
|
||||||
name='avatar'
|
|
||||||
id='avatar'
|
|
||||||
schema={{
|
|
||||||
size: undefined,
|
|
||||||
formats: undefined,
|
|
||||||
}}
|
|
||||||
component={FormImagePicker}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='App Role' labelFor='app_role'>
|
|
||||||
<Field
|
|
||||||
name='app_role'
|
|
||||||
id='app_role'
|
|
||||||
component={SelectField}
|
|
||||||
options={initialValues.app_role}
|
|
||||||
itemRef={'roles'}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
showField={'name'}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Custom Permissions' labelFor='custom_permissions'>
|
|
||||||
<Field
|
|
||||||
name='custom_permissions'
|
|
||||||
id='custom_permissions'
|
|
||||||
component={SelectFieldMany}
|
|
||||||
options={initialValues.custom_permissions}
|
|
||||||
itemRef={'permissions'}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
showField={'name'}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Password"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
name="password"
|
|
||||||
placeholder="password"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')}/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
|
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="UPDATE_USERS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'UPDATE_USERS'}
|
export default EditUsersPage;
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EditUsersPage
|
|
||||||
|
|||||||
@ -1,502 +1,266 @@
|
|||||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
import {
|
||||||
import Head from 'next/head'
|
mdiAccountOutline,
|
||||||
import React, { ReactElement } from 'react'
|
mdiKeyVariant,
|
||||||
import CardBox from '../../components/CardBox'
|
mdiShieldOutline,
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
mdiUpload,
|
||||||
import SectionMain from '../../components/SectionMain'
|
} from '@mdi/js';
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import Head from 'next/head';
|
||||||
import { getPageTitle } from '../../config'
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement } from 'react';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
|
||||||
import { Field, Form, Formik } from 'formik'
|
import {
|
||||||
import FormField from '../../components/FormField'
|
actionButtonClassName,
|
||||||
import BaseDivider from '../../components/BaseDivider'
|
EntityAsideCard,
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
EntityIntro,
|
||||||
import BaseButton from '../../components/BaseButton'
|
EntitySection,
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
formatName,
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
inputClassName,
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
} from '../../components/AdminEntity/PageKit';
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
import FormImagePicker from '../../components/FormImagePicker';
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { SelectField } from '../../components/SelectField';
|
||||||
import { SelectField } from '../../components/SelectField'
|
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
import { create } from '../../stores/users/usersSlice'
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
import { useAppDispatch } from '../../stores/hooks'
|
import { create } from '../../stores/users/usersSlice';
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router';
|
||||||
import moment from 'moment';
|
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
|
app_role: null,
|
||||||
|
avatar: [],
|
||||||
firstName: '',
|
custom_permissions: [],
|
||||||
|
disabled: false,
|
||||||
|
email: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
password: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAvatarUrl(values: typeof initialValues) {
|
||||||
|
if (!Array.isArray(values.avatar) || values.avatar.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.avatar[0]?.publicUrl || '';
|
||||||
|
|
||||||
lastName: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
phoneNumber: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
email: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
disabled: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
avatar: [],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app_role: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
custom_permissions: [],
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitials(values: typeof initialValues) {
|
||||||
|
const fullName = [values.firstName, values.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
|
if (!fullName) {
|
||||||
|
return 'U';
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullName
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() || '')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
const UsersNew = () => {
|
const UsersNew = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
|
const { loading } = useAppSelector((state) => state.users);
|
||||||
|
|
||||||
|
const handleSubmit = async (data: typeof initialValues) => {
|
||||||
|
await dispatch(create(data)).unwrap();
|
||||||
|
await router.push('/users/users-list');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
|
||||||
await dispatch(create(data))
|
|
||||||
await router.push('/users/users-list')
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('New Item')}</title>
|
<title>{getPageTitle('Create user')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<Formik initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
||||||
{''}
|
{({ isSubmitting, values }) => {
|
||||||
</SectionTitleLineWithButton>
|
const avatarUrl = getAvatarUrl(values);
|
||||||
<CardBox>
|
const initials = getInitials(values);
|
||||||
<Formik
|
|
||||||
initialValues={
|
return (
|
||||||
|
<Form>
|
||||||
initialValues
|
<div className="flex w-full flex-col gap-5">
|
||||||
|
<EntityIntro
|
||||||
}
|
backHref="/users/users-list"
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
backLabel="Back to users"
|
||||||
>
|
description="Set identity, access, avatar, and an initial password in one clean place before the user enters the workspace."
|
||||||
<Form>
|
kicker="User"
|
||||||
|
title="Create a new user."
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
label="First Name"
|
<div className="space-y-5">
|
||||||
>
|
<EntitySection
|
||||||
<Field
|
description="Basic identity fields that appear across the workspace and admin area."
|
||||||
name="firstName"
|
icon={mdiAccountOutline}
|
||||||
placeholder="First Name"
|
title="Identity"
|
||||||
/>
|
>
|
||||||
</FormField>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="firstName">
|
||||||
|
First name
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="firstName" name="firstName" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="lastName">
|
||||||
|
Last name
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="lastName" name="lastName" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="phoneNumber">
|
||||||
|
Phone number
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="phoneNumber" name="phoneNumber" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="email" name="email" type="email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
<FormField
|
<EntitySection
|
||||||
label="Last Name"
|
description="Upload a profile image so the navbar, chat, and workspace feel personal from the start."
|
||||||
>
|
icon={mdiUpload}
|
||||||
<Field
|
title="Avatar"
|
||||||
name="lastName"
|
>
|
||||||
placeholder="Last Name"
|
<Field
|
||||||
/>
|
color="info"
|
||||||
</FormField>
|
component={FormImagePicker}
|
||||||
|
icon={mdiUpload}
|
||||||
|
id="avatar"
|
||||||
|
label="Avatar"
|
||||||
|
name="avatar"
|
||||||
|
path="users/avatar"
|
||||||
|
schema={{ formats: undefined, size: undefined }}
|
||||||
|
/>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Control whether the user is active, which role they inherit, and which custom overrides apply."
|
||||||
|
icon={mdiShieldOutline}
|
||||||
|
title="Access"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
||||||
|
<label className="mb-3 block text-[13px] font-medium text-slate-700" htmlFor="disabled">
|
||||||
|
Disabled
|
||||||
|
</label>
|
||||||
|
<Field component={SwitchField} id="disabled" name="disabled" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="app_role">
|
||||||
|
App role
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
component={SelectField}
|
||||||
<FormField
|
id="app_role"
|
||||||
label="Phone Number"
|
itemRef="roles"
|
||||||
>
|
name="app_role"
|
||||||
<Field
|
options={values.app_role}
|
||||||
name="phoneNumber"
|
showField="name"
|
||||||
placeholder="Phone Number"
|
/>
|
||||||
/>
|
</div>
|
||||||
</FormField>
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="custom_permissions">
|
||||||
|
Custom permissions
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
component={SelectFieldMany}
|
||||||
|
id="custom_permissions"
|
||||||
|
itemRef="permissions"
|
||||||
|
name="custom_permissions"
|
||||||
|
options={values.custom_permissions}
|
||||||
|
showField="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
|
|
||||||
|
<EntitySection
|
||||||
|
description="Set the first password now so the account is ready to sign in immediately."
|
||||||
|
icon={mdiKeyVariant}
|
||||||
|
title="Security"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="password">
|
||||||
|
Initial password
|
||||||
|
</label>
|
||||||
|
<Field className={inputClassName} id="password" name="password" type="password" />
|
||||||
|
</div>
|
||||||
|
</EntitySection>
|
||||||
<FormField
|
</div>
|
||||||
label="E-Mail"
|
|
||||||
>
|
<div className="space-y-5">
|
||||||
<Field
|
<EntityAsideCard title="Preview">
|
||||||
name="email"
|
<div className="flex items-start gap-3">
|
||||||
placeholder="E-Mail"
|
{avatarUrl ? (
|
||||||
/>
|
<img
|
||||||
</FormField>
|
alt={formatName(values)}
|
||||||
|
className="h-14 w-14 rounded-[12px] border border-slate-200 object-cover"
|
||||||
|
src={avatarUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex h-14 w-14 items-center justify-center rounded-[12px] border border-slate-200 bg-slate-50 text-[18px] font-semibold text-slate-700">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[16px] font-semibold text-slate-900">
|
||||||
|
{formatName(values)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">{values.email || 'No email set yet'}</p>
|
||||||
|
<p className="mt-1 text-[13px] text-slate-500">
|
||||||
|
{values.app_role?.name || 'Default role'} · {values.disabled ? 'Disabled' : 'Active'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
|
||||||
|
<EntityAsideCard title="Actions">
|
||||||
|
<p className="text-[13px] text-slate-500">
|
||||||
|
Save the account once identity, access, and password look right.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
||||||
|
href="/users/users-list"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSubmitting || loading ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityAsideCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<FormField label='Disabled' labelFor='disabled'>
|
</Form>
|
||||||
<Field
|
);
|
||||||
name='disabled'
|
|
||||||
id='disabled'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField>
|
|
||||||
<Field
|
|
||||||
label='Avatar'
|
|
||||||
color='info'
|
|
||||||
icon={mdiUpload}
|
|
||||||
path={'users/avatar'}
|
|
||||||
name='avatar'
|
|
||||||
id='avatar'
|
|
||||||
schema={{
|
|
||||||
size: undefined,
|
|
||||||
formats: undefined,
|
|
||||||
}}
|
}}
|
||||||
component={FormImagePicker}
|
</Formik>
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="App Role" labelFor="app_role">
|
|
||||||
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Custom Permissions' labelFor='custom_permissions'>
|
|
||||||
<Field
|
|
||||||
name='custom_permissions'
|
|
||||||
id='custom_permissions'
|
|
||||||
itemRef={'permissions'}
|
|
||||||
options={[]}
|
|
||||||
component={SelectFieldMany}>
|
|
||||||
</Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')}/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
UsersNew.getLayout = function getLayout(page: ReactElement) {
|
UsersNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission="CREATE_USERS">{page}</LayoutAuthenticated>;
|
||||||
<LayoutAuthenticated
|
};
|
||||||
|
|
||||||
permission={'CREATE_USERS'}
|
export default UsersNew;
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UsersNew
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user