Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c82c2166f2 | |||
| c8c2ca8c5b | |||
| 8b5c940976 | |||
| 235fb538e1 | |||
| 27820254e7 | |||
| e9f0165006 | |||
| c88342369f | |||
| 18285851ac | |||
| b1b37f5cf1 | |||
| b81e1cf47d | |||
| 9ab3530a80 | |||
| c37c854b07 | |||
| b0a1cfdab5 |
@@ -0,0 +1,20 @@
|
|||||||
|
# API Configuration
|
||||||
|
# Base URL da API do HGTX
|
||||||
|
VITE_API_BASE_URL=https://prod-hgtx-intelligence-n8n.hgtx.com.br
|
||||||
|
VITE_BASE_URL_OPEN_CODEX_APIs=https://codex-api.hgtx.com.br/api/
|
||||||
|
VITE_BASE_URL_HGTX_CORE_API=https://core-api.hgtx.com.br/api/
|
||||||
|
VITE_BASE_URL_HGTX_CORE=https://core.hgtx.com.br/
|
||||||
|
|
||||||
|
# API Key para autenticação
|
||||||
|
# IMPORTANTE: Mantenha esta chave segura e não compartilhe publicamente
|
||||||
|
VITE_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
# User Configuration
|
||||||
|
# Configure com os dados do usuário/estabelecimento
|
||||||
|
VITE_USER_EMAIL=user@example.com
|
||||||
|
VITE_ESTABELECIMENTO_ID=1
|
||||||
|
|
||||||
|
# Instruções:
|
||||||
|
# 1. Copie este arquivo para .env
|
||||||
|
# 2. Substitua os valores acima com as configurações reais
|
||||||
|
# 3. NUNCA commite o arquivo .env no git
|
||||||
@@ -12,6 +12,11 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="pt-br">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -10,11 +10,11 @@
|
|||||||
<meta property="og:title" content="HGTX Codex - AI Interface System" />
|
<meta property="og:title" content="HGTX Codex - AI Interface System" />
|
||||||
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
<meta property="og:image" content="/favicon.png" />
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@lovable_dev" />
|
<meta name="twitter:site" content="@hgtx" />
|
||||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
<meta name="twitter:image" content="/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+268
@@ -37,12 +37,14 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -3324,6 +3326,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.21",
|
"version": "10.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||||
@@ -3362,6 +3370,17 @@
|
|||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -3436,6 +3455,19 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -3583,6 +3615,18 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
@@ -3792,6 +3836,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-node-es": {
|
"node_modules/detect-node-es": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
@@ -3820,6 +3873,20 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
@@ -3867,6 +3934,51 @@
|
|||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -4253,6 +4365,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||||
@@ -4269,6 +4401,22 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@@ -4306,6 +4454,30 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-nonce": {
|
"node_modules/get-nonce": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
@@ -4315,6 +4487,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
@@ -4384,6 +4569,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graphemer": {
|
"node_modules/graphemer": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||||
@@ -4401,6 +4598,33 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -4605,6 +4829,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jwt-decode": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5174,6 +5406,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5196,6 +5437,27 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -5631,6 +5893,12 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@@ -40,12 +40,14 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
+21
-2
@@ -2,10 +2,13 @@ import { Toaster } from "@/components/ui/toaster";
|
|||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import Redirect from "./pages/Redirect";
|
||||||
|
import { GlobalFunctions } from "./GlobalFunctions";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -17,9 +20,15 @@ const App = () => (
|
|||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route
|
||||||
|
path="/redir/:gsikey"
|
||||||
|
element={<Redirect />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="/*" element={GlobalFunctions.isUsuarioLogado() ? <Index /> : <HGTXLogin />} />
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
|
<Route path="/404" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -28,3 +37,13 @@ const App = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
|
||||||
|
const HGTXLogin = () =>{
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
return(<></>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
import { RedirectService } from './pages/RedirectService';
|
||||||
|
|
||||||
|
export class GlobalFunctions {
|
||||||
|
|
||||||
|
public static getTranferAreaObj(): TransferAreaResponse {
|
||||||
|
let objRetorno: TransferAreaResponse = {
|
||||||
|
aplicativoID: 0,
|
||||||
|
aplicativoNome: "",
|
||||||
|
areaTransferenciaID: 0,
|
||||||
|
dataRegistro: "",
|
||||||
|
estabelecimentoID: 0,
|
||||||
|
usuarioID: 0,
|
||||||
|
usuarioEmail: "",
|
||||||
|
usuarioNome: "",
|
||||||
|
aplicativoShareCode: "",
|
||||||
|
nivelAcesso: 0
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const objTransferStr = sessionStorage.getItem("TRANSFER_AREA");
|
||||||
|
if (
|
||||||
|
objTransferStr != null &&
|
||||||
|
objTransferStr != undefined &&
|
||||||
|
objTransferStr.trim() != ""
|
||||||
|
) {
|
||||||
|
const objTransfer = <TransferAreaResponse>JSON.parse(objTransferStr);
|
||||||
|
objRetorno = objTransfer;
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
return objRetorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getRedirectComponent(): string {
|
||||||
|
let objRetorno = "";
|
||||||
|
try {
|
||||||
|
const objTransfer = GlobalFunctions.getTranferAreaObj();
|
||||||
|
if (objTransfer != null) {
|
||||||
|
// let objRedir = objTransfer.find(x => x.name == 'REDIRECT');
|
||||||
|
// objRetorno = objRedir?.value ?? '';
|
||||||
|
objRetorno = objTransfer.urlDestino ?? "";
|
||||||
|
if (objRetorno.startsWith("/")) objRetorno = objRetorno.substring(1);
|
||||||
|
} else {
|
||||||
|
objRetorno = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
//console.log(error);
|
||||||
|
objRetorno = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return objRetorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getTransferProperty(propertyName: TransferAreaProperties): any {
|
||||||
|
let objRetorno: any = "";
|
||||||
|
try {
|
||||||
|
const objTransfer = this.getTranferAreaObj();
|
||||||
|
if (objTransfer != null) {
|
||||||
|
//let obj: any = objTransfer[propertyName];
|
||||||
|
const element = objTransfer[propertyName];
|
||||||
|
objRetorno = element;
|
||||||
|
} else {
|
||||||
|
objRetorno = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
objRetorno = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return objRetorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isUsuarioLogado(): boolean {
|
||||||
|
let jsonUsuario = sessionStorage.getItem('usuarioLogado');
|
||||||
|
if (jsonUsuario) {
|
||||||
|
let tokenDecode: any = GlobalFunctions.getUsuarioLogado();
|
||||||
|
let d: Date = new Date();
|
||||||
|
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
|
||||||
|
if (dateAtual > tokenDecode.exp) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static tokenExpired(tokenDecode: any): boolean {
|
||||||
|
let d: Date = new Date();
|
||||||
|
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
|
||||||
|
if (dateAtual > tokenDecode.exp) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static handleNavigateBack(): void {
|
||||||
|
try {
|
||||||
|
// Verifica se existe histórico de navegação
|
||||||
|
if (window.history && window.history.length > 1) {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenta fechar a janela
|
||||||
|
if (!window.closed) {
|
||||||
|
try {
|
||||||
|
const windowRef = window.open('', '_self');
|
||||||
|
if (windowRef) {
|
||||||
|
windowRef.close();
|
||||||
|
|
||||||
|
// Pequeno timeout para verificar se a janela foi fechada
|
||||||
|
setTimeout(() => {
|
||||||
|
// Se ainda estiver aberta, redireciona
|
||||||
|
if (!windowRef.closed) {
|
||||||
|
window.location.href = "https://core.hgtx.com.br/aplicativos/";
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn("Não foi possível fechar a janela:", closeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não conseguiu fechar, redireciona
|
||||||
|
window.location.href = "https://core.hgtx.com.br/aplicativos/";
|
||||||
|
} catch (error) {
|
||||||
|
// Em caso de qualquer erro, garante o redirecionamento
|
||||||
|
console.error("Erro ao tentar navegar:", error);
|
||||||
|
window.location.href = "https://core.hgtx.com.br/aplicativos/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getUsuarioLogado(): UsuarioLogadoData {
|
||||||
|
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
|
||||||
|
try {
|
||||||
|
const objJwt = JSON.parse(jsonUsuario);
|
||||||
|
//return jwt_decode(objJwt.token);
|
||||||
|
return jwtDecode(objJwt.token);
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
EID: "0",
|
||||||
|
email: "",
|
||||||
|
exp: 0,
|
||||||
|
iat: 0,
|
||||||
|
jti: "",
|
||||||
|
name: "",
|
||||||
|
nbf: 0,
|
||||||
|
UID: "0",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getUsuarioLogadoToken(): string {
|
||||||
|
let retorno = "";
|
||||||
|
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
|
||||||
|
try {
|
||||||
|
const objJwt = JSON.parse(jsonUsuario);
|
||||||
|
retorno = objJwt.token;
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
return retorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getUsuarioLogadoRefreshToken(): string {
|
||||||
|
let retorno = "";
|
||||||
|
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
|
||||||
|
try {
|
||||||
|
const objJwt = JSON.parse(jsonUsuario);
|
||||||
|
retorno = objJwt.refreshToken;
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
return retorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getPerfilUsuario(): PerfilUsuario | null {
|
||||||
|
let retorno: PerfilUsuario | null = null;
|
||||||
|
const jsonUsuario = sessionStorage.getItem("perfilUsuario") ?? "";
|
||||||
|
try {
|
||||||
|
const objJwt = <PerfilUsuario>JSON.parse(jsonUsuario);
|
||||||
|
retorno = objJwt;
|
||||||
|
} catch (error) {
|
||||||
|
retorno = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return retorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getHighUserProfile(): number {
|
||||||
|
let retorno = 6;
|
||||||
|
const objPerfil = GlobalFunctions.getPerfilUsuario();
|
||||||
|
|
||||||
|
if (objPerfil) {
|
||||||
|
const tiposPerfil: number[] = objPerfil.results.map((v) => v.tipoPerfil);
|
||||||
|
retorno = Math.min(...tiposPerfil);
|
||||||
|
}
|
||||||
|
|
||||||
|
return retorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getToken(): Promise<string> {
|
||||||
|
let token: string = GlobalFunctions.getUsuarioLogadoToken();
|
||||||
|
|
||||||
|
const tokenDecode: any = GlobalFunctions.getUsuarioLogado();
|
||||||
|
|
||||||
|
let currentDate = new Date();
|
||||||
|
|
||||||
|
// JWT exp is in seconds
|
||||||
|
if ((tokenDecode.exp * 1000) < currentDate.getTime()) {
|
||||||
|
//console.log("Token expired.");
|
||||||
|
let gsikey: number = parseInt(sessionStorage.getItem('gsikey') ?? '0');
|
||||||
|
RedirectService.loadTransferArea(gsikey).then((response) => {
|
||||||
|
if(response.data.errorCode == 0){
|
||||||
|
sessionStorage.setItem('TRANSFER_AREA',JSON.stringify(response.data.areTrs));
|
||||||
|
sessionStorage.setItem('usuarioLogado',JSON.stringify({
|
||||||
|
token: response.data.jwt.token,
|
||||||
|
refreshToken: response.data.jwt.refreshToken
|
||||||
|
}));
|
||||||
|
|
||||||
|
token = response.data.jwt.token;
|
||||||
|
}
|
||||||
|
// else if(error !== 1401){
|
||||||
|
// navigate('/',{replace: true});
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// else {
|
||||||
|
// //console.log("Valid token");
|
||||||
|
// result = true;
|
||||||
|
// }
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsuarioLogadoData {
|
||||||
|
UID: string;
|
||||||
|
EID: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
jti: string;
|
||||||
|
nbf: number;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferAreaResponse {
|
||||||
|
estabelecimentoID: number;
|
||||||
|
nivelAcesso: number;
|
||||||
|
aplicativoID: number;
|
||||||
|
usuarioID: number;
|
||||||
|
areaTransferenciaID: number;
|
||||||
|
dataRegistro: string;
|
||||||
|
urlBase?: string;
|
||||||
|
urlDestino?: string;
|
||||||
|
aplicativoNome: string;
|
||||||
|
usuarioNome: string;
|
||||||
|
usuarioEmail: string;
|
||||||
|
aplicativoShareCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransferAreaProperties {
|
||||||
|
EstabelecimentoCodigo = "estabelecimentoID",
|
||||||
|
UsuarioCodigo = "usuarioID",
|
||||||
|
UsuarioNome = "usuarioNome",
|
||||||
|
UsuarioEmail = "usuarioEmail",
|
||||||
|
APP_COD = "aplicativoID",
|
||||||
|
APP_TITULO = "aplicativoNome",
|
||||||
|
areaTransferenciaID = "areaTransferenciaID",
|
||||||
|
urlBase = "urlBase",
|
||||||
|
urlDestino = "urlDestino",
|
||||||
|
aplicativoShareCode = "aplicativoShareCode",
|
||||||
|
nivelAcesso = "nivelAcesso",
|
||||||
|
}
|
||||||
|
|
||||||
|
type PerfilUsuario = {
|
||||||
|
errorCode: number;
|
||||||
|
errorMessage: string;
|
||||||
|
results: PerfilUsuarioResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PerfilUsuarioResult = {
|
||||||
|
tipoPerfil: number;
|
||||||
|
itens: PerfilUsuarioItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PerfilUsuarioItem = {
|
||||||
|
id: number;
|
||||||
|
descricao: string;
|
||||||
|
identificador: string;
|
||||||
|
};
|
||||||
@@ -27,7 +27,7 @@ interface ChatHeaderProps {
|
|||||||
|
|
||||||
export const ChatHeader = ({
|
export const ChatHeader = ({
|
||||||
showModelSelector = false,
|
showModelSelector = false,
|
||||||
selectedModel = "ChatGPT 4.1",
|
selectedModel = "GPT-4o",
|
||||||
onModelChange,
|
onModelChange,
|
||||||
systemPrompt = "Você é um assistente útil e prestativo.",
|
systemPrompt = "Você é um assistente útil e prestativo.",
|
||||||
onSystemPromptChange
|
onSystemPromptChange
|
||||||
@@ -36,10 +36,17 @@ export const ChatHeader = ({
|
|||||||
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
|
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
|
||||||
|
|
||||||
const models = [
|
const models = [
|
||||||
"ChatGPT 4.1",
|
"GPT-4.1",
|
||||||
"ChatGPT 5",
|
"GPT-4o",
|
||||||
|
"GPT-5 Mini",
|
||||||
|
"Gemini 2.0 Flash",
|
||||||
"Claude Sonnet 4.5",
|
"Claude Sonnet 4.5",
|
||||||
"Claude Opus 4.1",
|
"DeepSeek V3.2 Chat",
|
||||||
|
"DeepSeek V3.2 Reasoner",
|
||||||
|
"Gemini 2.5 Flash",
|
||||||
|
"Gemini 2.5 Flash-Lite",
|
||||||
|
"Claude Haiku 4.5",
|
||||||
|
"GPT-4o Mini",
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSavePersonality = () => {
|
const handleSavePersonality = () => {
|
||||||
@@ -50,8 +57,8 @@ export const ChatHeader = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-6 py-4 flex items-center justify-between">
|
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" />
|
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" />
|
||||||
<span className="text-sm text-muted-foreground">Online</span>
|
<span className="text-sm text-muted-foreground">Online</span>
|
||||||
@@ -60,9 +67,9 @@ export const ChatHeader = ({
|
|||||||
{showModelSelector && onModelChange && (
|
{showModelSelector && onModelChange && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2 glass-effect">
|
<Button variant="outline" className="gap-1 md:gap-2 glass-effect text-xs md:text-sm">
|
||||||
<span className="font-medium">{selectedModel}</span>
|
<span className="font-medium truncate max-w-[80px] md:max-w-none">{selectedModel}</span>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
|
||||||
@@ -84,11 +91,11 @@ export const ChatHeader = ({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 glass-effect"
|
className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
|
||||||
onClick={() => setTempSystemPrompt(systemPrompt)}
|
onClick={() => setTempSystemPrompt(systemPrompt)}
|
||||||
>
|
>
|
||||||
<UserCog className="w-4 h-4" />
|
<UserCog className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
<span className="font-medium">Personalidade</span>
|
<span className="hidden md:inline font-medium">Personalidade</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -120,7 +127,7 @@ export const ChatHeader = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="hidden md:block text-sm text-muted-foreground">
|
||||||
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"}
|
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface FullScreenLoaderProps {
|
||||||
|
show: boolean;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FullScreenLoader({ show, text = "Carregando..." }: FullScreenLoaderProps) {
|
||||||
|
const [visible, setVisible] = useState(show);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) setVisible(true);
|
||||||
|
else {
|
||||||
|
// adiciona um pequeno atraso para suavizar o fade-out
|
||||||
|
const timeout = setTimeout(() => setVisible(false), 200);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
if (!visible && !show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-200 ${
|
||||||
|
show ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3 p-6 rounded-2xl bg-card shadow-lg border border-border transition-all duration-200">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
||||||
|
<p className="text-card-foreground text-sm font-medium">{text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+35
-18
@@ -1,16 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MessageSquare, Image, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Sparkles } from "lucide-react";
|
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
activeTab: "chat" | "images" | "audio" | "bots" | "agent";
|
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
|
||||||
onTabChange: (tab: "chat" | "images" | "audio" | "bots" | "agent") => void;
|
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = "chat" | "images" | "audio" | "bots" | "agent";
|
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
|
||||||
|
|
||||||
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
@@ -18,16 +19,17 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare },
|
{ id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare },
|
||||||
{ id: "images" as TabType, label: "Imagens", icon: Image },
|
{ id: "images" as TabType, label: "Imagens", icon: Image },
|
||||||
{ id: "audio" as TabType, label: "Áudio", icon: Mic },
|
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
|
||||||
|
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
|
||||||
{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
||||||
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Sparkles },
|
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Brain },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
{!isSidebarCollapsed ? (
|
{!isSidebarCollapsed ? (
|
||||||
<aside className="w-72 bg-sidebar border-r border-sidebar-border flex flex-col">
|
<aside className="hidden md:flex md:w-72 bg-sidebar border-r border-sidebar-border flex-col">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 border-b border-sidebar-border flex items-center justify-between">
|
<div className="p-6 border-b border-sidebar-border flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -74,14 +76,14 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
|
<Button onClick={GlobalFunctions.handleNavigateBack} variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
) : (
|
) : (
|
||||||
<aside className="w-16 bg-sidebar border-r border-sidebar-border flex flex-col">
|
<aside className="hidden md:flex md:w-16 bg-sidebar border-r border-sidebar-border flex-col">
|
||||||
{/* Collapsed Header with Expand Button */}
|
{/* Collapsed Header with Expand Button */}
|
||||||
<div className="p-3 border-b border-sidebar-border space-y-2">
|
<div className="p-3 border-b border-sidebar-border space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -127,12 +129,7 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
{/* Theme & Settings Icons */}
|
{/* Theme & Settings Icons */}
|
||||||
<div className="p-3 border-t border-sidebar-border space-y-2">
|
<div className="p-3 border-t border-sidebar-border space-y-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="w-full text-sidebar-foreground" title="Voltar">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="w-full text-sidebar-foreground"
|
|
||||||
title="Voltar"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,9 +137,29 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
<main className="flex-1 flex flex-col overflow-hidden w-full">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
{/* Mobile Navigation */}
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-sidebar border-t border-sidebar-border flex items-center justify-around py-2 z-50">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all ${
|
||||||
|
isActive
|
||||||
|
? "text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-5 h-5 ${isActive ? "text-primary" : ""}`} />
|
||||||
|
<span className="text-xs font-medium">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Download, Send, Sparkles } from "lucide-react";
|
|
||||||
import { LegalOpinion } from "./AgentView";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface AgentChatProps {
|
|
||||||
selectedOpinion: LegalOpinion | null;
|
|
||||||
isCreatingNew: boolean;
|
|
||||||
onOpinionCreated: (opinion: LegalOpinion) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentChat = ({
|
|
||||||
selectedOpinion,
|
|
||||||
isCreatingNew,
|
|
||||||
onOpinionCreated,
|
|
||||||
}: AgentChatProps) => {
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [instructions, setInstructions] = useState("");
|
|
||||||
const [category, setCategory] = useState("");
|
|
||||||
const [generatedContent, setGeneratedContent] = useState("");
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
if (!instructions.trim()) {
|
|
||||||
toast({
|
|
||||||
title: "Instruções necessárias",
|
|
||||||
description: "Por favor, forneça instruções para gerar o parecer.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGenerating(true);
|
|
||||||
|
|
||||||
// Simulação de chamada à IA - aqui você integraria com a API real
|
|
||||||
setTimeout(() => {
|
|
||||||
const mockContent = `PARECER JURÍDICO
|
|
||||||
|
|
||||||
TÍTULO: ${title || "Parecer Jurídico"}
|
|
||||||
|
|
||||||
${instructions}
|
|
||||||
|
|
||||||
ANÁLISE:
|
|
||||||
|
|
||||||
Este parecer foi gerado com base nas instruções fornecidas. A análise considera os seguintes aspectos legais:
|
|
||||||
|
|
||||||
1. Fundamentação Legal
|
|
||||||
2. Precedentes Judiciais
|
|
||||||
3. Doutrina Aplicável
|
|
||||||
4. Conclusão e Recomendações
|
|
||||||
|
|
||||||
CONCLUSÃO:
|
|
||||||
|
|
||||||
Com base na análise realizada, conclui-se que...
|
|
||||||
|
|
||||||
___________________________
|
|
||||||
Parecer gerado em ${new Date().toLocaleDateString('pt-BR')}`;
|
|
||||||
|
|
||||||
setGeneratedContent(mockContent);
|
|
||||||
setIsGenerating(false);
|
|
||||||
|
|
||||||
if (isCreatingNew) {
|
|
||||||
const newOpinion: LegalOpinion = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
title: title || "Novo Parecer",
|
|
||||||
content: mockContent,
|
|
||||||
createdAt: new Date(),
|
|
||||||
category: category || undefined,
|
|
||||||
};
|
|
||||||
onOpinionCreated(newOpinion);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Parecer gerado com sucesso!",
|
|
||||||
description: "O parecer está pronto para download.",
|
|
||||||
});
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadDocx = () => {
|
|
||||||
// Aqui você implementaria a geração real do DOCX
|
|
||||||
// Por enquanto, vamos criar um arquivo de texto
|
|
||||||
const content = generatedContent || selectedOpinion?.content || "";
|
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title || selectedOpinion?.title || 'parecer'}.txt`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Download iniciado",
|
|
||||||
description: "O parecer está sendo baixado.",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayContent = generatedContent || selectedOpinion?.content;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full bg-background">
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground">
|
|
||||||
{selectedOpinion ? selectedOpinion.title : "Novo Parecer Jurídico"}
|
|
||||||
</h2>
|
|
||||||
{selectedOpinion && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Criado em {new Date(selectedOpinion.createdAt).toLocaleDateString('pt-BR')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full p-6 space-y-4">
|
|
||||||
{(isCreatingNew || !selectedOpinion) && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-foreground">Título do Parecer</label>
|
|
||||||
<Input
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-foreground">Categoria</label>
|
|
||||||
<Input
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
|
||||||
placeholder="Ex: Direito Civil, Trabalhista, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-foreground">
|
|
||||||
{selectedOpinion ? "Instruções para novo modelo" : "Instruções para gerar o parecer"}
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={instructions}
|
|
||||||
onChange={(e) => setInstructions(e.target.value)}
|
|
||||||
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
|
|
||||||
className="min-h-[120px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="w-full gap-2"
|
|
||||||
>
|
|
||||||
{isGenerating ? (
|
|
||||||
<>Gerando parecer...</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles className="w-4 h-4" />
|
|
||||||
{selectedOpinion ? "Gerar Novo Modelo com IA" : "Gerar Parecer com IA"}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{displayContent && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium text-foreground">Parecer Gerado</label>
|
|
||||||
<Button
|
|
||||||
onClick={handleDownloadDocx}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
Baixar DOCX
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="h-[300px] rounded-md border border-border p-4">
|
|
||||||
<pre className="whitespace-pre-wrap font-sans text-sm text-foreground">
|
|
||||||
{displayContent}
|
|
||||||
</pre>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,6 +3,15 @@ import { Search, FileText } from "lucide-react";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
import { LegalOpinion } from "./AgentView";
|
import { LegalOpinion } from "./AgentView";
|
||||||
|
|
||||||
interface AgentSearchProps {
|
interface AgentSearchProps {
|
||||||
@@ -51,6 +60,8 @@ const mockOpinionsDatabase: LegalOpinion[] = [
|
|||||||
export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<LegalOpinion[]>([]);
|
const [searchResults, setSearchResults] = useState<LegalOpinion[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(5);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
if (!searchTerm.trim()) {
|
if (!searchTerm.trim()) {
|
||||||
@@ -66,8 +77,13 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(searchResults.length / itemsPerPage);
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const paginatedResults = searchResults.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background">
|
<div className="flex flex-col h-full bg-background">
|
||||||
<div className="p-6 border-b border-border space-y-4">
|
<div className="p-6 border-b border-border space-y-4">
|
||||||
@@ -89,10 +105,11 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
<ScrollArea className="flex-1 p-6">
|
<ScrollArea className="flex-1 p-6">
|
||||||
{searchResults.length > 0 ? (
|
{searchResults.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{searchResults.map((opinion) => (
|
{paginatedResults.map((opinion) => (
|
||||||
<div
|
<div
|
||||||
key={opinion.id}
|
key={opinion.id}
|
||||||
className="p-4 border border-border rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
|
className="p-4 border border-border rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
@@ -124,6 +141,67 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="p-4 border-t border-border space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Itens por página:</span>
|
||||||
|
<Select
|
||||||
|
value={itemsPerPage.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setItemsPerPage(Number(value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-20">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="20">20</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Mostrando {startIndex + 1}-{Math.min(startIndex + itemsPerPage, searchResults.length)} de {searchResults.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={() => setCurrentPage(page)}
|
||||||
|
isActive={currentPage === page}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
|
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { Plus, Search, Trash2, Eye } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { LegalOpinion } from "./AgentView";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
|
|
||||||
interface AgentSidebarProps {
|
|
||||||
opinions: LegalOpinion[];
|
|
||||||
onCreateNew: () => void;
|
|
||||||
onSelectOpinion: (opinion: LegalOpinion) => void;
|
|
||||||
onDeleteOpinion: (id: string) => void;
|
|
||||||
onSearch: () => void;
|
|
||||||
selectedOpinionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentSidebar = ({
|
|
||||||
opinions,
|
|
||||||
onCreateNew,
|
|
||||||
onSelectOpinion,
|
|
||||||
onDeleteOpinion,
|
|
||||||
onSearch,
|
|
||||||
selectedOpinionId,
|
|
||||||
}: AgentSidebarProps) => {
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
|
|
||||||
const filteredOpinions = opinions.filter(op =>
|
|
||||||
op.title.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-80 border-r border-border bg-sidebar flex flex-col h-full">
|
|
||||||
<div className="p-4 border-b border-border space-y-3">
|
|
||||||
<h2 className="text-xl font-bold text-sidebar-foreground">Agente de Parecer</h2>
|
|
||||||
<Button onClick={onCreateNew} className="w-full gap-2">
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Novo Parecer
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onSearch} variant="outline" className="w-full gap-2">
|
|
||||||
<Search className="w-4 h-4" />
|
|
||||||
Pesquisar Base de Pareceres
|
|
||||||
</Button>
|
|
||||||
<Input
|
|
||||||
placeholder="Buscar pareceres..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-2">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Título</TableHead>
|
|
||||||
<TableHead className="w-[120px]">Data</TableHead>
|
|
||||||
<TableHead className="w-[100px] text-center">Ações</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredOpinions.map((opinion) => (
|
|
||||||
<TableRow
|
|
||||||
key={opinion.id}
|
|
||||||
className={selectedOpinionId === opinion.id ? "bg-sidebar-accent" : ""}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div>
|
|
||||||
<p className="truncate">{opinion.title}</p>
|
|
||||||
{opinion.category && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{opinion.category}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
|
||||||
{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => onSelectOpinion(opinion)}
|
|
||||||
title="Visualizar"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => onDeleteOpinion(opinion.id)}
|
|
||||||
title="Excluir"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{filteredOpinions.length === 0 && (
|
|
||||||
<div className="text-center text-muted-foreground py-8">
|
|
||||||
<p className="text-sm">Nenhum parecer encontrado</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,23 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AgentSidebar } from "./AgentSidebar";
|
import { Plus, Search, Eye, Trash2, ArrowUpDown } from "lucide-react";
|
||||||
import { AgentChat } from "./AgentChat";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { OpinionDialog } from "./OpinionDialog";
|
||||||
import { AgentSearch } from "./AgentSearch";
|
import { AgentSearch } from "./AgentSearch";
|
||||||
|
|
||||||
export interface LegalOpinion {
|
export interface LegalOpinion {
|
||||||
@@ -11,64 +28,285 @@ export interface LegalOpinion {
|
|||||||
category?: string;
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortField = "title" | "createdAt" | "category";
|
||||||
|
type SortOrder = "asc" | "desc";
|
||||||
|
|
||||||
export const AgentView = () => {
|
export const AgentView = () => {
|
||||||
const [opinions, setOpinions] = useState<LegalOpinion[]>([]);
|
const [opinions, setOpinions] = useState<LegalOpinion[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
const [sortField, setSortField] = useState<SortField>("createdAt");
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [selectedOpinion, setSelectedOpinion] = useState<LegalOpinion | null>(null);
|
const [selectedOpinion, setSelectedOpinion] = useState<LegalOpinion | null>(null);
|
||||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleSort = (field: SortField) => {
|
||||||
setIsCreatingNew(true);
|
if (sortField === field) {
|
||||||
setSelectedOpinion(null);
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
setShowSearch(false);
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectOpinion = (opinion: LegalOpinion) => {
|
const filteredOpinions = opinions.filter(
|
||||||
setSelectedOpinion(opinion);
|
(op) =>
|
||||||
setIsCreatingNew(false);
|
op.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
setShowSearch(false);
|
op.category?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
};
|
);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const sortedOpinions = [...filteredOpinions].sort((a, b) => {
|
||||||
setShowSearch(true);
|
const multiplier = sortOrder === "asc" ? 1 : -1;
|
||||||
setIsCreatingNew(false);
|
|
||||||
setSelectedOpinion(null);
|
if (sortField === "createdAt") {
|
||||||
};
|
return multiplier * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
const aValue = a[sortField] || "";
|
||||||
|
const bValue = b[sortField] || "";
|
||||||
|
return multiplier * aValue.toString().localeCompare(bValue.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedOpinions.length / itemsPerPage);
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const paginatedOpinions = sortedOpinions.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
const handleOpinionCreated = (newOpinion: LegalOpinion) => {
|
const handleOpinionCreated = (newOpinion: LegalOpinion) => {
|
||||||
setOpinions([newOpinion, ...opinions]);
|
setOpinions([newOpinion, ...opinions]);
|
||||||
setSelectedOpinion(newOpinion);
|
setIsDialogOpen(false);
|
||||||
setIsCreatingNew(false);
|
setSelectedOpinion(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteOpinion = (id: string) => {
|
const handleDeleteOpinion = (id: string) => {
|
||||||
setOpinions(opinions.filter(op => op.id !== id));
|
setOpinions(opinions.filter(op => op.id !== id));
|
||||||
if (selectedOpinion?.id === id) {
|
|
||||||
setSelectedOpinion(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewOpinion = (opinion: LegalOpinion) => {
|
||||||
|
setSelectedOpinion(opinion);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewOpinion = () => {
|
||||||
|
setSelectedOpinion(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFromSearch = (opinion: LegalOpinion) => {
|
||||||
|
setSelectedOpinion(opinion);
|
||||||
|
setShowSearch(false);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showSearch) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex flex-col h-full">
|
||||||
<AgentSidebar
|
<div className="p-6 border-b border-border">
|
||||||
opinions={opinions}
|
<Button variant="outline" onClick={() => setShowSearch(false)}>
|
||||||
onCreateNew={handleCreateNew}
|
Voltar para Pareceres
|
||||||
onSelectOpinion={handleSelectOpinion}
|
</Button>
|
||||||
onDeleteOpinion={handleDeleteOpinion}
|
</div>
|
||||||
onSearch={handleSearch}
|
<AgentSearch onSelectOpinion={handleSelectFromSearch} />
|
||||||
selectedOpinionId={selectedOpinion?.id}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||||
|
<div className="p-3 md:p-6 border-b border-border">
|
||||||
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||||
|
<h1 className="text-xl md:text-3xl font-bold text-foreground">Agente de Parecer Jurídico</h1>
|
||||||
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
|
<Button onClick={() => setShowSearch(true)} variant="outline" className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
||||||
|
<Search className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
<span className="hidden sm:inline">Pesquisar Base</span>
|
||||||
|
<span className="sm:hidden">Base</span>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNewOpinion} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
||||||
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
<span className="hidden sm:inline">Novo Parecer</span>
|
||||||
|
<span className="sm:hidden">Novo</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar pareceres..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full md:max-w-sm text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex items-center gap-2 justify-between md:justify-start">
|
||||||
{showSearch ? (
|
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
|
||||||
<AgentSearch onSelectOpinion={handleSelectOpinion} />
|
<Select
|
||||||
|
value={itemsPerPage.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setItemsPerPage(Number(value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-16 md:w-20">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="20">20</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
|
<div className="border rounded-lg overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-[200px]">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSort("title")}
|
||||||
|
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
|
||||||
|
>
|
||||||
|
Título
|
||||||
|
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSort("category")}
|
||||||
|
className="flex items-center gap-1 font-semibold text-sm"
|
||||||
|
>
|
||||||
|
Categoria
|
||||||
|
<ArrowUpDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSort("createdAt")}
|
||||||
|
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
|
||||||
|
>
|
||||||
|
Data
|
||||||
|
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedOpinions.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-12 text-muted-foreground">
|
||||||
|
{searchTerm
|
||||||
|
? "Nenhum parecer encontrado"
|
||||||
|
: "Nenhum parecer criado ainda. Clique em 'Novo Parecer' para começar."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
<AgentChat
|
paginatedOpinions.map((opinion) => (
|
||||||
selectedOpinion={selectedOpinion}
|
<TableRow key={opinion.id}>
|
||||||
isCreatingNew={isCreatingNew}
|
<TableCell className="font-medium text-xs md:text-sm">{opinion.title}</TableCell>
|
||||||
onOpinionCreated={handleOpinionCreated}
|
<TableCell className="hidden md:table-cell text-sm">{opinion.category || "-"}</TableCell>
|
||||||
/>
|
<TableCell className="hidden sm:table-cell text-xs md:text-sm">
|
||||||
|
{new Date(opinion.createdAt).toLocaleDateString("pt-BR")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewOpinion(opinion)}
|
||||||
|
title="Visualizar"
|
||||||
|
className="h-7 w-7 md:h-9 md:w-9"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteOpinion(opinion.id)}
|
||||||
|
className="text-destructive hover:text-destructive h-7 w-7 md:h-9 md:w-9"
|
||||||
|
title="Excluir"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
|
||||||
|
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
|
||||||
|
{startIndex + 1}-{Math.min(startIndex + itemsPerPage, sortedOpinions.length)} de {sortedOpinions.length}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1 md:gap-2 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="text-xs md:text-sm px-2 md:px-4"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Anterior</span>
|
||||||
|
<span className="sm:hidden">Ant</span>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||||
|
let page;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
page = i + 1;
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
page = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
page = totalPages - 4 + i;
|
||||||
|
} else {
|
||||||
|
page = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={page}
|
||||||
|
variant={currentPage === page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(page)}
|
||||||
|
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="text-xs md:text-sm px-2 md:px-4"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Próxima</span>
|
||||||
|
<span className="sm:hidden">Prox</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OpinionDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={setIsDialogOpen}
|
||||||
|
selectedOpinion={selectedOpinion}
|
||||||
|
onOpinionCreated={handleOpinionCreated}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { Download, Sparkles } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { LegalOpinion } from "./AgentView";
|
||||||
|
|
||||||
|
interface OpinionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
selectedOpinion: LegalOpinion | null;
|
||||||
|
onOpinionCreated: (opinion: LegalOpinion) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpinionDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selectedOpinion,
|
||||||
|
onOpinionCreated,
|
||||||
|
}: OpinionDialogProps) => {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [instructions, setInstructions] = useState("");
|
||||||
|
const [generatedContent, setGeneratedContent] = useState("");
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOpinion) {
|
||||||
|
setTitle(selectedOpinion.title);
|
||||||
|
setCategory(selectedOpinion.category || "");
|
||||||
|
setGeneratedContent(selectedOpinion.content);
|
||||||
|
setInstructions("");
|
||||||
|
} else {
|
||||||
|
setTitle("");
|
||||||
|
setCategory("");
|
||||||
|
setInstructions("");
|
||||||
|
setGeneratedContent("");
|
||||||
|
}
|
||||||
|
}, [selectedOpinion]);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!instructions.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Instruções necessárias",
|
||||||
|
description: "Por favor, forneça instruções para gerar o parecer.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Título necessário",
|
||||||
|
description: "Por favor, forneça um título para o parecer.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/gepam/parecer-tecnico",
|
||||||
|
{
|
||||||
|
titulo: title,
|
||||||
|
categoria: category,
|
||||||
|
instrucoes: instructions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = response.data.parecer || response.data.content || JSON.stringify(response.data, null, 2);
|
||||||
|
|
||||||
|
setGeneratedContent(content);
|
||||||
|
setIsGenerating(false);
|
||||||
|
|
||||||
|
const newOpinion: LegalOpinion = {
|
||||||
|
id: selectedOpinion?.id || Date.now().toString(),
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
createdAt: new Date(),
|
||||||
|
category: category || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
onOpinionCreated(newOpinion);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Parecer gerado com sucesso!",
|
||||||
|
description: "O parecer está pronto para download.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao gerar parecer:", error);
|
||||||
|
setIsGenerating(false);
|
||||||
|
|
||||||
|
let errorMessage = "Não foi possível gerar o parecer. Tente novamente.";
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response) {
|
||||||
|
errorMessage = error.response.data?.message || `Erro do servidor: ${error.response.status}`;
|
||||||
|
} else if (error.request) {
|
||||||
|
errorMessage = "Sem resposta do servidor. Verifique sua conexão.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Erro ao gerar parecer",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadDocx = () => {
|
||||||
|
const content = generatedContent;
|
||||||
|
const blob = new Blob([content], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${title || "parecer"}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Download iniciado",
|
||||||
|
description: "O parecer está sendo baixado.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] md:max-w-4xl max-h-[90vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedOpinion ? "Gerar Novo Modelo do Parecer" : "Novo Parecer Jurídico"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedOpinion
|
||||||
|
? "Forneça instruções para gerar um novo modelo baseado neste parecer"
|
||||||
|
: "Preencha os dados e instruções para gerar um novo parecer com IA"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[calc(90vh-120px)] pr-4">
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Título do Parecer *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
|
||||||
|
disabled={!!selectedOpinion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Categoria</Label>
|
||||||
|
<Input
|
||||||
|
id="category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder="Ex: Direito Civil, Trabalhista, etc."
|
||||||
|
disabled={!!selectedOpinion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="instructions">
|
||||||
|
Instruções para gerar o parecer *
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="instructions"
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
|
||||||
|
className="min-h-[150px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>Gerando parecer...</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Gerar Parecer com IA
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{generatedContent && (
|
||||||
|
<div className="space-y-2 pt-4 border-t">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Parecer Gerado</Label>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadDocx}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Baixar DOCX
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[300px] rounded-md border p-4">
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||||
|
{generatedContent}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -270,22 +270,22 @@ export const AudioView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
||||||
<Tabs defaultValue="transcription" className="space-y-6">
|
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-4 glass-effect">
|
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4 glass-effect text-xs md:text-sm">
|
||||||
<TabsTrigger value="transcription">
|
<TabsTrigger value="transcription" className="text-xs md:text-sm">
|
||||||
Transcrição
|
Transcrição
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="generation">Geração de Voz</TabsTrigger>
|
<TabsTrigger value="generation" className="text-xs md:text-sm">Geração</TabsTrigger>
|
||||||
<TabsTrigger value="transcription-history">
|
<TabsTrigger value="transcription-history" className="text-xs md:text-sm">
|
||||||
Histórico Transcrições
|
Hist. Trans.
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="audio-history">
|
<TabsTrigger value="audio-history" className="text-xs md:text-sm">
|
||||||
Histórico Áudios
|
Hist. Áud.
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Mic, Play, Download, Trash2, Search } from "lucide-react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { audioGenerationService, VOICE_OPTIONS, VoiceType } from "@/services/audioGeneration";
|
||||||
|
|
||||||
|
interface GeneratedAudio {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
voice: string;
|
||||||
|
voiceLabel: string;
|
||||||
|
audioUrl: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenerationView = () => {
|
||||||
|
const [textToSpeech, setTextToSpeech] = useState("");
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
|
||||||
|
const [generatedAudio, setGeneratedAudio] = useState<GeneratedAudio | null>(null);
|
||||||
|
const [audioHistory, setAudioHistory] = useState<GeneratedAudio[]>([]);
|
||||||
|
const [audioSearchQuery, setAudioSearchQuery] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedAudios = localStorage.getItem('audioHistory');
|
||||||
|
if (savedAudios) {
|
||||||
|
setAudioHistory(JSON.parse(savedAudios));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGenerateAudio = async () => {
|
||||||
|
// Valida o texto antes de enviar
|
||||||
|
const validation = audioGenerationService.validateText(textToSpeech);
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast({
|
||||||
|
title: "Texto inválido",
|
||||||
|
description: validation.error,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Chama o serviço de geração de áudio
|
||||||
|
const response = await audioGenerationService.generateAudio({
|
||||||
|
message: textToSpeech,
|
||||||
|
voice: selectedVoice,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verifica se a geração foi bem-sucedida
|
||||||
|
if (response.success) {
|
||||||
|
console.log('URL do áudio gerado:', response.audio_url);
|
||||||
|
|
||||||
|
const audio: GeneratedAudio = {
|
||||||
|
id: response.audio_generation_id,
|
||||||
|
text: response.message,
|
||||||
|
voice: selectedVoice,
|
||||||
|
voiceLabel: VOICE_OPTIONS[selectedVoice].label,
|
||||||
|
audioUrl: response.audio_url,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setGeneratedAudio(audio);
|
||||||
|
|
||||||
|
const newHistory = [audio, ...audioHistory].slice(0, 10);
|
||||||
|
setAudioHistory(newHistory);
|
||||||
|
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Áudio gerado com sucesso",
|
||||||
|
description: `Voz: ${VOICE_OPTIONS[selectedVoice].label}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limpa o campo de texto após sucesso
|
||||||
|
setTextToSpeech("");
|
||||||
|
} else {
|
||||||
|
throw new Error('Erro ao gerar áudio');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erro na geração de áudio:', error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Erro na geração",
|
||||||
|
description: error.message || "Não foi possível gerar o áudio. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAudio = (audio: GeneratedAudio) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = audio.audioUrl;
|
||||||
|
a.download = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Download iniciado",
|
||||||
|
description: "O arquivo de áudio está sendo baixado.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAudio = (audioId: string) => {
|
||||||
|
if (generatedAudio?.id === audioId) {
|
||||||
|
setGeneratedAudio(null);
|
||||||
|
}
|
||||||
|
const newHistory = audioHistory.filter(a => a.id !== audioId);
|
||||||
|
setAudioHistory(newHistory);
|
||||||
|
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Áudio removido",
|
||||||
|
description: "O áudio foi removido do histórico.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAudios = audioHistory.filter(item =>
|
||||||
|
item.voiceLabel.toLowerCase().includes(audioSearchQuery.toLowerCase()) ||
|
||||||
|
item.text.toLowerCase().includes(audioSearchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
|
<ChatHeader />
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
||||||
|
<Tabs defaultValue="generation" className="space-y-4 md:space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
|
||||||
|
<TabsTrigger value="generation" className="text-xs md:text-sm">
|
||||||
|
Geração
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history" className="text-xs md:text-sm">
|
||||||
|
Histórico
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="generation" className="space-y-6">
|
||||||
|
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Converter Texto em Voz
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Digite o texto para converter
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={textToSpeech}
|
||||||
|
onChange={(e) => setTextToSpeech(e.target.value)}
|
||||||
|
placeholder="Digite o texto que deseja converter em fala..."
|
||||||
|
className="min-h-[120px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Tipo de Voz</label>
|
||||||
|
<Select value={selectedVoice} onValueChange={(value) => setSelectedVoice(value as VoiceType)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(VOICE_OPTIONS).map(([key, voice]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{voice.label} - {voice.gender}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">{VOICE_OPTIONS[selectedVoice].label}</p>
|
||||||
|
<span className="text-xs text-muted-foreground">Modelo: OpenAI TTS-1</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Estilo:</strong> {VOICE_OPTIONS[selectedVoice].style}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{VOICE_OPTIONS[selectedVoice].description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateAudio}
|
||||||
|
disabled={!textToSpeech.trim() || isProcessing}
|
||||||
|
className="gap-2 cyber-glow w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Mic className="w-4 h-4" />
|
||||||
|
{isProcessing ? "Gerando..." : "Gerar Áudio"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Gerando áudio...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generatedAudio && (
|
||||||
|
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">Áudio Gerado</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Voz: {generatedAudio.voiceLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1"
|
||||||
|
onClick={() => handleDownloadAudio(generatedAudio)}
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
Baixar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="gap-1"
|
||||||
|
onClick={() => handleDeleteAudio(generatedAudio.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Excluir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
|
<p className="text-sm mb-3">{generatedAudio.text}</p>
|
||||||
|
<audio
|
||||||
|
key={generatedAudio.id}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
preload="metadata"
|
||||||
|
>
|
||||||
|
<source src={generatedAudio.audioUrl} type="audio/mpeg" />
|
||||||
|
Seu navegador não suporta o elemento de áudio.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="history" className="space-y-6">
|
||||||
|
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Histórico de Áudios</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{audioHistory.length} {audioHistory.length === 1 ? 'item' : 'itens'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={audioSearchQuery}
|
||||||
|
onChange={(e) => setAudioSearchQuery(e.target.value)}
|
||||||
|
placeholder="Buscar por voz ou conteúdo..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredAudios.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<Mic className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>{audioSearchQuery ? 'Nenhum áudio encontrado' : 'Nenhum áudio no histórico'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredAudios.map((audio) => (
|
||||||
|
<div key={audio.id} className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-medium">Voz: {audio.voiceLabel}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(audio.timestamp).toLocaleDateString('pt-BR')} às{' '}
|
||||||
|
{new Date(audio.timestamp).toLocaleTimeString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDownloadAudio(audio)}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDeleteAudio(audio.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{audio.text}
|
||||||
|
</p>
|
||||||
|
<audio
|
||||||
|
key={audio.id}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
preload="metadata"
|
||||||
|
>
|
||||||
|
<source src={audio.audioUrl} type="audio/mpeg" />
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Upload, Download, Trash2, FileAudio, Copy, Search } from "lucide-react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { transcriptionService } from "@/services/transcription";
|
||||||
|
|
||||||
|
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
|
||||||
|
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||||
|
|
||||||
|
interface TranscriptionResult {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: Date;
|
||||||
|
audioUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TranscriptionView = () => {
|
||||||
|
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||||
|
const [transcriptionResult, setTranscriptionResult] = useState<TranscriptionResult | null>(null);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [transcriptionHistory, setTranscriptionHistory] = useState<TranscriptionResult[]>([]);
|
||||||
|
const [transcriptionSearchQuery, setTranscriptionSearchQuery] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTranscriptions = localStorage.getItem('transcriptionHistory');
|
||||||
|
if (savedTranscriptions) {
|
||||||
|
setTranscriptionHistory(JSON.parse(savedTranscriptions));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Valida o arquivo usando o serviço
|
||||||
|
const validation = transcriptionService.validateAudioFile(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast({
|
||||||
|
title: "Arquivo inválido",
|
||||||
|
description: validation.error,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(file);
|
||||||
|
handleTranscription(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTranscription = async (file: File) => {
|
||||||
|
setIsTranscribing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Chama o serviço de transcrição
|
||||||
|
const response = await transcriptionService.transcribeAudio({
|
||||||
|
audioFile: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verifica se a transcrição foi bem-sucedida
|
||||||
|
if (response.success) {
|
||||||
|
const result: TranscriptionResult = {
|
||||||
|
id: response.transcription_id,
|
||||||
|
fileName: file.name,
|
||||||
|
text: response.message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
audioUrl: response.audio_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTranscriptionResult(result);
|
||||||
|
|
||||||
|
const newHistory = [result, ...transcriptionHistory].slice(0, 10);
|
||||||
|
setTranscriptionHistory(newHistory);
|
||||||
|
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Transcrição concluída",
|
||||||
|
description: "Seu áudio foi transcrito com sucesso!",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || 'Erro ao transcrever áudio');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erro na transcrição:', error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Erro na transcrição",
|
||||||
|
description: error.message || "Não foi possível transcrever o áudio. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsTranscribing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTranscription = () => {
|
||||||
|
setTranscriptionResult(null);
|
||||||
|
setSelectedFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTranscriptionFromHistory = (transcriptionId: string) => {
|
||||||
|
if (transcriptionResult?.id === transcriptionId) {
|
||||||
|
setTranscriptionResult(null);
|
||||||
|
}
|
||||||
|
const newHistory = transcriptionHistory.filter(t => t.id !== transcriptionId);
|
||||||
|
setTranscriptionHistory(newHistory);
|
||||||
|
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Transcrição removida",
|
||||||
|
description: "A transcrição foi removida do histórico.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyTranscription = () => {
|
||||||
|
if (transcriptionResult) {
|
||||||
|
navigator.clipboard.writeText(transcriptionResult.text);
|
||||||
|
toast({
|
||||||
|
title: "Texto copiado",
|
||||||
|
description: "A transcrição foi copiada para a área de transferência",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTranscription = () => {
|
||||||
|
if (transcriptionResult) {
|
||||||
|
const blob = new Blob([transcriptionResult.text], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `transcricao_${transcriptionResult.fileName}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTranscriptions = transcriptionHistory.filter(item =>
|
||||||
|
item.fileName.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
|
||||||
|
item.text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
|
<ChatHeader />
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
||||||
|
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
|
||||||
|
<TabsTrigger value="transcription" className="text-xs md:text-sm">
|
||||||
|
Transcrição
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history" className="text-xs md:text-sm">
|
||||||
|
Histórico
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="transcription" className="space-y-6">
|
||||||
|
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Converter Áudio em Texto
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Envie um arquivo de áudio para transcrição (máx. 25MB)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Formatos: {SUPPORTED_FORMATS.join(', ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="audio-upload"
|
||||||
|
className="hidden"
|
||||||
|
accept={SUPPORTED_FORMATS.map(f => `.${f}`).join(',')}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="audio-upload"
|
||||||
|
className="border-2 border-dashed border-border rounded-xl p-12 text-center space-y-4 hover:border-primary/50 transition-colors cursor-pointer block"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
{selectedFile ? (
|
||||||
|
<FileAudio className="w-8 h-8 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-8 h-8 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedFile ? selectedFile.name : "Clique para selecionar ou arraste o arquivo"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{selectedFile
|
||||||
|
? `${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
|
||||||
|
: "Máximo 25 MB"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!selectedFile && (
|
||||||
|
<Button variant="outline" className="gap-2" type="button">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Selecionar Arquivo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isTranscribing && (
|
||||||
|
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Transcrevendo áudio...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{transcriptionResult && (
|
||||||
|
<div className="glass-effect rounded-xl p-6 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">{transcriptionResult.fileName}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Transcrito {new Date(transcriptionResult.timestamp).toLocaleTimeString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1"
|
||||||
|
onClick={handleCopyTranscription}
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
Copiar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1"
|
||||||
|
onClick={handleDownloadTranscription}
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
Baixar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="gap-1"
|
||||||
|
onClick={handleDeleteTranscription}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Excluir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{transcriptionResult.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="history" className="space-y-6">
|
||||||
|
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Histórico de Transcrições</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{transcriptionHistory.length} {transcriptionHistory.length === 1 ? 'item' : 'itens'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={transcriptionSearchQuery}
|
||||||
|
onChange={(e) => setTranscriptionSearchQuery(e.target.value)}
|
||||||
|
placeholder="Buscar por nome ou conteúdo..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredTranscriptions.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<FileAudio className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredTranscriptions.map((item) => (
|
||||||
|
<div key={item.id} className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium truncate">{item.fileName}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(item.timestamp).toLocaleDateString('pt-BR')} às{' '}
|
||||||
|
{new Date(item.timestamp).toLocaleTimeString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDeleteTranscriptionFromHistory(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{item.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -95,19 +95,19 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0">
|
||||||
<div className="border-b border-border p-6">
|
<div className="border-b border-border p-3 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
<h2 className="text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||||
<Bot className="w-6 h-6" />
|
<Bot className="w-5 h-5 md:w-6 md:h-6" />
|
||||||
Gerenciador de Bots
|
Gerenciador de Bots
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p>
|
<p className="text-sm md:text-base text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button className="w-full md:w-auto text-sm">
|
||||||
<Bot className="w-4 h-4 mr-2" />
|
<Bot className="w-4 h-4 mr-2" />
|
||||||
Criar Bot
|
Criar Bot
|
||||||
</Button>
|
</Button>
|
||||||
@@ -167,7 +167,7 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
{bots.length === 0 ? (
|
{bots.length === 0 ? (
|
||||||
<Card className="max-w-2xl mx-auto mt-12">
|
<Card className="max-w-2xl mx-auto mt-12">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -190,45 +190,48 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Nome</TableHead>
|
<TableHead className="text-xs md:text-sm">Nome</TableHead>
|
||||||
<TableHead>Modelo</TableHead>
|
<TableHead className="hidden md:table-cell text-sm">Modelo</TableHead>
|
||||||
<TableHead className="text-right">Ações</TableHead>
|
<TableHead className="text-right text-xs md:text-sm">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{currentBots.map((bot) => (
|
{currentBots.map((bot) => (
|
||||||
<TableRow key={bot.id}>
|
<TableRow key={bot.id}>
|
||||||
<TableCell className="font-medium">{bot.name}</TableCell>
|
<TableCell className="font-medium text-xs md:text-sm">{bot.name}</TableCell>
|
||||||
<TableCell>{bot.model}</TableCell>
|
<TableCell className="hidden md:table-cell text-sm">{bot.model}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-1 justify-end flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onStartChat(bot)}
|
onClick={() => onStartChat(bot)}
|
||||||
|
className="text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||||
Conversar
|
<span className="hidden md:inline">Conversar</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openEditDialog(bot)}
|
onClick={() => openEditDialog(bot)}
|
||||||
|
className="text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
<Edit className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||||
Editar
|
<span className="hidden md:inline">Editar</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openDeleteDialog(bot)}
|
onClick={() => openDeleteDialog(bot)}
|
||||||
|
className="text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||||
Excluir
|
<span className="hidden md:inline">Excluir</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { chatService } from "@/services/chat";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSendMessage: (message: string, files?: File[]) => void;
|
onSendMessage: (message: string, files?: File[]) => void;
|
||||||
@@ -67,21 +68,54 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
|
const newFiles = [...attachedFiles, ...files];
|
||||||
|
|
||||||
// Validate file size (max 10MB per file)
|
// Valida quantidade máxima de anexos (5)
|
||||||
const validFiles = files.filter((file) => {
|
if (newFiles.length > chatService.getMaxAttachments()) {
|
||||||
|
toast({
|
||||||
|
title: "Limite de anexos excedido",
|
||||||
|
description: `Você pode anexar no máximo ${chatService.getMaxAttachments()} arquivos por mensagem.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida tipos de arquivo e tamanho
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
// Valida tamanho (max 10MB por arquivo)
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
toast({
|
toast({
|
||||||
title: "Arquivo muito grande",
|
title: "Arquivo muito grande",
|
||||||
description: `${file.name} excede o limite de 10MB`,
|
description: `${file.name} excede o limite de 10MB`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
setAttachedFiles([...attachedFiles, ...validFiles]);
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida formatos permitidos
|
||||||
|
const allFiles = [...attachedFiles, ...validFiles];
|
||||||
|
const validation = chatService.validateAttachments(allFiles);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast({
|
||||||
|
title: "Formato de arquivo não permitido",
|
||||||
|
description: validation.error,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachedFiles(allFiles);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
@@ -92,19 +126,19 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-4">
|
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
|
||||||
<div className="max-w-4xl mx-auto space-y-3">
|
<div className="max-w-4xl mx-auto space-y-2 md:space-y-3">
|
||||||
{/* Attached Files Preview */}
|
{/* Attached Files Preview */}
|
||||||
{attachedFiles.length > 0 && (
|
{attachedFiles.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1 md:gap-2">
|
||||||
{attachedFiles.map((file, index) => (
|
{attachedFiles.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-muted border border-border text-sm"
|
className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1 md:py-1.5 rounded-lg bg-muted border border-border text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
<Paperclip className="w-3 h-3 text-primary" />
|
<Paperclip className="w-3 h-3 text-primary" />
|
||||||
<span className="font-medium">{file.name}</span>
|
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="hidden md:inline text-xs text-muted-foreground">
|
||||||
({(file.size / 1024).toFixed(1)} KB)
|
({(file.size / 1024).toFixed(1)} KB)
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -121,12 +155,12 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message Input and Buttons */}
|
{/* Message Input and Buttons */}
|
||||||
<div className="flex gap-2 items-end">
|
<div className="flex gap-1 md:gap-2 items-end">
|
||||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
||||||
{/* Resize Handle */}
|
{/* Resize Handle - Hidden on mobile */}
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className={`w-full h-6 flex items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
|
className={`hidden md:flex w-full h-6 items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
|
||||||
isDragging ? 'bg-primary/20' : ''
|
isDragging ? 'bg-primary/20' : ''
|
||||||
}`}
|
}`}
|
||||||
title="Arraste para cima para aumentar"
|
title="Arraste para cima para aumentar"
|
||||||
@@ -135,51 +169,52 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Textarea */}
|
{/* Textarea */}
|
||||||
<div className="px-3 pb-3">
|
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Digite sua mensagem..."
|
placeholder="Digite sua mensagem..."
|
||||||
style={{ height: `${textareaHeight}px` }}
|
style={{ height: window.innerWidth < 768 ? '80px' : `${textareaHeight}px` }}
|
||||||
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto"
|
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto text-sm md:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1 md:gap-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
accept=".pdf,.txt,.doc,.docx,.xls,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp"
|
accept={chatService.getAllowedFileExtensions()}
|
||||||
|
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-11 w-11 glass-effect"
|
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title="Anexar arquivo"
|
title="Anexar arquivo"
|
||||||
>
|
>
|
||||||
<Paperclip className="w-5 h-5" />
|
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-11 w-11 cyber-glow"
|
className="h-9 w-9 md:h-11 md:w-11 cyber-glow"
|
||||||
disabled={!message.trim() && attachedFiles.length === 0}
|
disabled={!message.trim() && attachedFiles.length === 0}
|
||||||
>
|
>
|
||||||
<Send className="w-5 h-5" />
|
<Send className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="hidden md:block text-xs text-muted-foreground text-center">
|
||||||
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. 10MB por arquivo
|
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()} anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,22 +30,22 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex gap-4 py-6 px-6 group hover:bg-muted/10 transition-colors ${
|
className={`flex gap-2 md:gap-4 py-4 md:py-6 px-3 md:px-6 group hover:bg-muted/10 transition-colors ${
|
||||||
isAssistant ? "justify-start" : "justify-end"
|
isAssistant ? "justify-start" : "justify-end"
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => setShowActions(true)}
|
onMouseEnter={() => setShowActions(true)}
|
||||||
onMouseLeave={() => setShowActions(false)}
|
onMouseLeave={() => setShowActions(false)}
|
||||||
>
|
>
|
||||||
{isAssistant && (
|
{isAssistant && (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-1 md:gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary">
|
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary">
|
||||||
<Bot className="w-5 h-5 text-primary-foreground" />
|
<Bot className="w-4 h-4 md:w-5 md:h-5 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{model && (
|
{model && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30"
|
className="hidden md:block text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30"
|
||||||
>
|
>
|
||||||
{model}
|
{model}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -53,7 +53,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex-1 max-w-3xl space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
|
<div className={`flex-1 max-w-3xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
{attachments && attachments.length > 0 && (
|
{attachments && attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
@@ -73,13 +73,13 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`inline-block px-4 py-3 rounded-xl ${
|
className={`inline-block px-3 md:px-4 py-2 md:py-3 rounded-xl ${
|
||||||
isAssistant
|
isAssistant
|
||||||
? "bg-card border border-border"
|
? "bg-card border border-border"
|
||||||
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
|
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className={`leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}>
|
<p className={`text-sm md:text-base leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}>
|
||||||
{content}
|
{content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,8 +100,8 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isAssistant && (
|
{!isAssistant && (
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent">
|
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent">
|
||||||
<User className="w-5 h-5 text-accent" />
|
<User className="w-4 h-4 md:w-5 md:h-5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+290
-124
@@ -1,8 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { chatService, StoredChat, StoredFolder } from "@/services/chat";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -30,111 +34,153 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
interface Conversation {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
lastMessage: string;
|
|
||||||
timestamp: string;
|
|
||||||
folderId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatFolder {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
interface ChatSidebarProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
|
onSelectChat?: (chat: StoredChat) => void;
|
||||||
|
currentChatId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSidebarProps) => {
|
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: ChatSidebarProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
|
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
|
||||||
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
|
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
|
||||||
|
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
const [editingFolder, setEditingFolder] = useState<ChatFolder | null>(null);
|
const [editingFolder, setEditingFolder] = useState<StoredFolder | null>(null);
|
||||||
const [deletingFolder, setDeletingFolder] = useState<ChatFolder | null>(null);
|
const [deletingFolder, setDeletingFolder] = useState<StoredFolder | null>(null);
|
||||||
const [selectedConversation, setSelectedConversation] = useState<string | null>("1");
|
const [deletingChat, setDeletingChat] = useState<StoredChat | null>(null);
|
||||||
|
|
||||||
const [folders, setFolders] = useState<ChatFolder[]>([
|
// Estado carregado do localStorage via chatService
|
||||||
{ id: "work", name: "Trabalho" },
|
const [chats, setChats] = useState<StoredChat[]>([]);
|
||||||
{ id: "personal", name: "Pessoal" },
|
const [folders, setFolders] = useState<StoredFolder[]>([]);
|
||||||
]);
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([
|
// Carrega chats e pastas do localStorage quando o componente monta
|
||||||
{
|
useEffect(() => {
|
||||||
id: "1",
|
loadChatsAndFolders();
|
||||||
title: "Conversa sobre IA",
|
|
||||||
lastMessage: "Como funciona o machine learning?",
|
|
||||||
timestamp: "Há 5 min",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
title: "Projeto HGTX",
|
|
||||||
lastMessage: "Discutindo arquitetura do sistema",
|
|
||||||
timestamp: "Há 1 hora",
|
|
||||||
folderId: "work",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
title: "Ideias criativas",
|
|
||||||
lastMessage: "Brainstorm para novo produto",
|
|
||||||
timestamp: "Ontem",
|
|
||||||
folderId: "personal",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["work", "personal"]));
|
// Escuta evento customizado para recarregar quando chat é atualizado
|
||||||
|
const handleChatUpdate = () => {
|
||||||
|
loadChatsAndFolders();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('chatUpdated', handleChatUpdate);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('chatUpdated', handleChatUpdate);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadChatsAndFolders = () => {
|
||||||
|
const loadedChats = chatService.getAllChats();
|
||||||
|
const loadedFolders = chatService.getAllFolders();
|
||||||
|
|
||||||
|
setChats(loadedChats);
|
||||||
|
setFolders(loadedFolders);
|
||||||
|
|
||||||
|
// Expande todas as pastas por padrão
|
||||||
|
setExpandedFolders(new Set(loadedFolders.map(f => f.id)));
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateFolder = () => {
|
const handleCreateFolder = () => {
|
||||||
if (newFolderName.trim()) {
|
if (newFolderName.trim()) {
|
||||||
const newFolder: ChatFolder = {
|
try {
|
||||||
id: Date.now().toString(),
|
const newFolder: StoredFolder = {
|
||||||
|
id: chatService.generateChatId(), // Usa mesmo gerador de ID
|
||||||
name: newFolderName,
|
name: newFolderName,
|
||||||
|
createdAt: new Date(),
|
||||||
|
chatIds: [],
|
||||||
};
|
};
|
||||||
setFolders([...folders, newFolder]);
|
chatService.saveFolder(newFolder);
|
||||||
|
loadChatsAndFolders();
|
||||||
setNewFolderName("");
|
setNewFolderName("");
|
||||||
setIsCreateFolderOpen(false);
|
setIsCreateFolderOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Pasta criada",
|
||||||
|
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro ao criar pasta",
|
||||||
|
description: "Não foi possível criar a pasta. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditFolder = () => {
|
const handleEditFolder = () => {
|
||||||
if (editingFolder && newFolderName.trim()) {
|
if (editingFolder && newFolderName.trim()) {
|
||||||
setFolders(
|
try {
|
||||||
folders.map((f) =>
|
const updatedFolder: StoredFolder = {
|
||||||
f.id === editingFolder.id ? { ...f, name: newFolderName } : f
|
...editingFolder,
|
||||||
)
|
name: newFolderName,
|
||||||
);
|
};
|
||||||
|
chatService.saveFolder(updatedFolder);
|
||||||
|
loadChatsAndFolders();
|
||||||
setNewFolderName("");
|
setNewFolderName("");
|
||||||
setEditingFolder(null);
|
setEditingFolder(null);
|
||||||
setIsEditFolderOpen(false);
|
setIsEditFolderOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Pasta renomeada",
|
||||||
|
description: `Pasta renomeada para "${newFolderName}".`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro ao renomear pasta",
|
||||||
|
description: "Não foi possível renomear a pasta. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFolder = () => {
|
const handleDeleteFolder = () => {
|
||||||
if (deletingFolder) {
|
if (deletingFolder) {
|
||||||
// Remove folder and move conversations back to "no folder"
|
try {
|
||||||
setConversations(
|
// Remove chats da pasta (volta para "Sem Pasta")
|
||||||
conversations.map((conv) =>
|
const updatedChats = chats.map(chat => {
|
||||||
conv.folderId === deletingFolder.id ? { ...conv, folderId: undefined } : conv
|
if (chat.folderId === deletingFolder.id) {
|
||||||
)
|
const updated = { ...chat, folderId: undefined };
|
||||||
);
|
chatService.saveChat(updated);
|
||||||
setFolders(folders.filter((f) => f.id !== deletingFolder.id));
|
return updated;
|
||||||
|
}
|
||||||
|
return chat;
|
||||||
|
});
|
||||||
|
|
||||||
|
chatService.deleteFolder(deletingFolder.id);
|
||||||
|
loadChatsAndFolders();
|
||||||
setDeletingFolder(null);
|
setDeletingFolder(null);
|
||||||
setIsDeleteFolderOpen(false);
|
setIsDeleteFolderOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Pasta excluída",
|
||||||
|
description: "As conversas foram movidas para 'Sem Pasta'.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro ao excluir pasta",
|
||||||
|
description: "Não foi possível excluir a pasta. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditFolder = (folder: ChatFolder) => {
|
const openEditFolder = (folder: StoredFolder) => {
|
||||||
setEditingFolder(folder);
|
setEditingFolder(folder);
|
||||||
setNewFolderName(folder.name);
|
setNewFolderName(folder.name);
|
||||||
setIsEditFolderOpen(true);
|
setIsEditFolderOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteFolder = (folder: ChatFolder) => {
|
const openDeleteFolder = (folder: StoredFolder) => {
|
||||||
setDeletingFolder(folder);
|
setDeletingFolder(folder);
|
||||||
setIsDeleteFolderOpen(true);
|
setIsDeleteFolderOpen(true);
|
||||||
};
|
};
|
||||||
@@ -149,37 +195,112 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
setExpandedFolders(newExpanded);
|
setExpandedFolders(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveToFolder = (conversationId: string, folderId: string) => {
|
const moveToFolder = (chatId: string, folderId: string) => {
|
||||||
setConversations(
|
try {
|
||||||
conversations.map((conv) =>
|
const chat = chats.find(c => c.id === chatId);
|
||||||
conv.id === conversationId ? { ...conv, folderId } : conv
|
if (chat) {
|
||||||
)
|
const updatedChat: StoredChat = {
|
||||||
);
|
...chat,
|
||||||
|
folderId: folderId,
|
||||||
|
};
|
||||||
|
chatService.saveChat(updatedChat);
|
||||||
|
loadChatsAndFolders();
|
||||||
|
|
||||||
|
const folder = folders.find(f => f.id === folderId);
|
||||||
|
toast({
|
||||||
|
title: "Chat movido",
|
||||||
|
description: `Movido para a pasta "${folder?.name}".`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro ao mover chat",
|
||||||
|
description: "Não foi possível mover o chat. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromFolder = (conversationId: string) => {
|
const removeFromFolder = (chatId: string) => {
|
||||||
setConversations(
|
try {
|
||||||
conversations.map((conv) =>
|
const chat = chats.find(c => c.id === chatId);
|
||||||
conv.id === conversationId ? { ...conv, folderId: undefined } : conv
|
if (chat) {
|
||||||
)
|
const updatedChat: StoredChat = {
|
||||||
);
|
...chat,
|
||||||
|
folderId: undefined,
|
||||||
|
};
|
||||||
|
chatService.saveChat(updatedChat);
|
||||||
|
loadChatsAndFolders();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Chat removido da pasta",
|
||||||
|
description: "Chat movido para 'Sem Pasta'.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro ao remover chat",
|
||||||
|
description: "Não foi possível remover o chat da pasta.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredConversations = conversations.filter(
|
const handleDeleteChat = () => {
|
||||||
(conv) =>
|
if (deletingChat) {
|
||||||
conv.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
try {
|
||||||
conv.lastMessage.toLowerCase().includes(searchQuery.toLowerCase())
|
chatService.deleteChat(deletingChat.id);
|
||||||
);
|
loadChatsAndFolders();
|
||||||
|
setDeletingChat(null);
|
||||||
|
setIsDeleteChatOpen(false);
|
||||||
|
|
||||||
const conversationsWithoutFolder = filteredConversations.filter((c) => !c.folderId);
|
toast({
|
||||||
const conversationsByFolder = folders.reduce((acc, folder) => {
|
title: "Chat excluído",
|
||||||
acc[folder.id] = filteredConversations.filter((c) => c.folderId === folder.id);
|
description: "A conversa foi excluída com sucesso.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro ao excluir chat",
|
||||||
|
description: "Não foi possível excluir o chat. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteChat = (chat: StoredChat) => {
|
||||||
|
setDeletingChat(chat);
|
||||||
|
setIsDeleteChatOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChat = (chat: StoredChat) => {
|
||||||
|
if (onSelectChat) {
|
||||||
|
onSelectChat(chat);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrar chats pela busca
|
||||||
|
const filteredChats = chats.filter((chat) => {
|
||||||
|
const searchLower = searchQuery.toLowerCase();
|
||||||
|
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
||||||
|
const contentMatch = chat.messages.some(msg =>
|
||||||
|
msg.content.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
return titleMatch || contentMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separar chats sem pasta
|
||||||
|
const chatsWithoutFolder = filteredChats.filter((c) => !c.folderId);
|
||||||
|
|
||||||
|
// Agrupar chats por pasta
|
||||||
|
const chatsByFolder = folders.reduce((acc, folder) => {
|
||||||
|
acc[folder.id] = filteredChats.filter((c) => c.folderId === folder.id);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, Conversation[]>);
|
}, {} as Record<string, StoredChat[]>);
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="w-12 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
|
<div className="w-12 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -193,7 +314,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
|
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
|
||||||
{/* Header with collapse button */}
|
{/* Header with collapse button */}
|
||||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
|
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
|
||||||
@@ -330,7 +451,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
{folder.name}
|
{folder.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{conversationsByFolder[folder.id]?.length || 0}
|
{chatsByFolder[folder.id]?.length || 0}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -363,17 +484,18 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedFolders.has(folder.id) && conversationsByFolder[folder.id]?.length > 0 && (
|
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
||||||
<div className="ml-6 space-y-1">
|
<div className="ml-6 space-y-1">
|
||||||
{conversationsByFolder[folder.id].map((conv) => (
|
{chatsByFolder[folder.id].map((chat) => (
|
||||||
<ConversationItem
|
<ChatItem
|
||||||
key={conv.id}
|
key={chat.id}
|
||||||
conversation={conv}
|
chat={chat}
|
||||||
isSelected={selectedConversation === conv.id}
|
isSelected={currentChatId === chat.id}
|
||||||
onSelect={() => setSelectedConversation(conv.id)}
|
onSelect={() => handleSelectChat(chat)}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
onMoveToFolder={moveToFolder}
|
onMoveToFolder={moveToFolder}
|
||||||
onRemoveFromFolder={removeFromFolder}
|
onRemoveFromFolder={removeFromFolder}
|
||||||
|
onDelete={() => openDeleteChat(chat)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -381,48 +503,90 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Conversations without folder */}
|
{/* Chats without folder */}
|
||||||
{conversationsWithoutFolder.length > 0 && (
|
{chatsWithoutFolder.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
Sem Pasta
|
Sem Pasta
|
||||||
</div>
|
</div>
|
||||||
{conversationsWithoutFolder.map((conv) => (
|
{chatsWithoutFolder.map((chat) => (
|
||||||
<ConversationItem
|
<ChatItem
|
||||||
key={conv.id}
|
key={chat.id}
|
||||||
conversation={conv}
|
chat={chat}
|
||||||
isSelected={selectedConversation === conv.id}
|
isSelected={currentChatId === chat.id}
|
||||||
onSelect={() => setSelectedConversation(conv.id)}
|
onSelect={() => handleSelectChat(chat)}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
onMoveToFolder={moveToFolder}
|
onMoveToFolder={moveToFolder}
|
||||||
onRemoveFromFolder={removeFromFolder}
|
onRemoveFromFolder={removeFromFolder}
|
||||||
|
onDelete={() => openDeleteChat(chat)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Delete Chat Alert */}
|
||||||
|
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
||||||
|
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
Excluir
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConversationItemProps {
|
interface ChatItemProps {
|
||||||
conversation: Conversation;
|
chat: StoredChat;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
folders: ChatFolder[];
|
folders: StoredFolder[];
|
||||||
onMoveToFolder: (conversationId: string, folderId: string) => void;
|
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||||
onRemoveFromFolder: (conversationId: string) => void;
|
onRemoveFromFolder: (chatId: string) => void;
|
||||||
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConversationItem = ({
|
const ChatItem = ({
|
||||||
conversation,
|
chat,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
folders,
|
folders,
|
||||||
onMoveToFolder,
|
onMoveToFolder,
|
||||||
onRemoveFromFolder,
|
onRemoveFromFolder,
|
||||||
}: ConversationItemProps) => {
|
onDelete,
|
||||||
|
}: ChatItemProps) => {
|
||||||
|
// Pega a última mensagem do usuário
|
||||||
|
const lastUserMessage = chat.messages
|
||||||
|
.filter(m => m.role === 'user')
|
||||||
|
.slice(-1)[0];
|
||||||
|
|
||||||
|
// Formata timestamp relativo
|
||||||
|
const timeAgo = formatDistanceToNow(new Date(chat.updatedAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: ptBR,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limita o título a 40 caracteres
|
||||||
|
const truncatedTitle = chat.title.length > 40
|
||||||
|
? chat.title.substring(0, 40) + '...'
|
||||||
|
: chat.title;
|
||||||
|
|
||||||
|
// Limita a mensagem a 50 caracteres
|
||||||
|
const truncatedMessage = lastUserMessage?.content.length > 50
|
||||||
|
? lastUserMessage.content.substring(0, 50) + '...'
|
||||||
|
: lastUserMessage?.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
|
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
|
||||||
@@ -433,13 +597,17 @@ const ConversationItem = ({
|
|||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
|
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<p className="text-sm font-medium truncate">{conversation.title}</p>
|
<p className="text-sm font-medium truncate" title={chat.title}>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
{truncatedTitle}
|
||||||
{conversation.lastMessage}
|
|
||||||
</p>
|
</p>
|
||||||
|
{lastUserMessage && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate" title={lastUserMessage.content}>
|
||||||
|
{truncatedMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{conversation.timestamp}
|
{timeAgo}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -448,21 +616,16 @@ const ConversationItem = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-3 h-3" />
|
<MoreVertical className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||||
<DropdownMenuItem className="gap-2">
|
{chat.folderId && (
|
||||||
<Edit className="w-3 h-3" />
|
|
||||||
Renomear
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{conversation.folderId && (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => onRemoveFromFolder(conversation.id)}
|
onClick={() => onRemoveFromFolder(chat.id)}
|
||||||
>
|
>
|
||||||
<FolderInput className="w-3 h-3" />
|
<FolderInput className="w-3 h-3" />
|
||||||
Remover da Pasta
|
Remover da Pasta
|
||||||
@@ -473,14 +636,17 @@ const ConversationItem = ({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => onMoveToFolder(conversation.id, folder.id)}
|
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3" />
|
<Folder className="w-3 h-3" />
|
||||||
Mover para {folder.name}
|
Mover para {folder.name}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<DropdownMenuItem className="gap-2 text-destructive">
|
<DropdownMenuItem
|
||||||
|
className="gap-2 text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
Excluir
|
Excluir
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChatHeader } from "@/components/ChatHeader";
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
import { ChatMessage } from "./ChatMessage";
|
import { ChatMessage } from "./ChatMessage";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
import { ChatSidebar } from "./ChatSidebar";
|
import { ChatSidebar } from "./ChatSidebar";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { chatService, StoredChat } from "@/services/chat";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { getModelId } from "@/config/models";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,24 +21,61 @@ interface Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChatView = () => {
|
export const ChatView = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState("ChatGPT 4.1");
|
const [selectedModel, setSelectedModel] = useState("GPT-4o");
|
||||||
const [currentChatId, setCurrentChatId] = useState("1");
|
// Inicia com "0" - será atualizado com o chat_id real após primeira resposta da API
|
||||||
|
const [currentChatId, setCurrentChatId] = useState("0");
|
||||||
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
|
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||||
model: "ChatGPT 4.1",
|
model: "GPT-4o",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Salva o chat no localStorage sempre que as mensagens mudam
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 1) { // Salva apenas se houver mensagens além da inicial
|
||||||
|
saveCurrentChat();
|
||||||
|
// Dispara evento customizado para a sidebar recarregar
|
||||||
|
window.dispatchEvent(new Event('chatUpdated'));
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Função para salvar o chat atual
|
||||||
|
const saveCurrentChat = () => {
|
||||||
|
try {
|
||||||
|
const chatTitle = chatService.generateChatTitle(
|
||||||
|
messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
|
||||||
|
);
|
||||||
|
|
||||||
|
const storedChat: StoredChat = {
|
||||||
|
id: currentChatId,
|
||||||
|
title: chatTitle,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
model: selectedModel,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
messages: messages.map(msg => ({
|
||||||
|
...msg,
|
||||||
|
timestamp: new Date(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
chatService.saveChat(storedChat);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar chat:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleNewChat = () => {
|
const handleNewChat = () => {
|
||||||
const newChatId = Date.now().toString();
|
// Reseta para "0" - novo chat sempre começa com chat_id "0"
|
||||||
setCurrentChatId(newChatId);
|
setCurrentChatId("0");
|
||||||
setMessages([
|
setMessages([
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -44,9 +84,31 @@ export const ChatView = () => {
|
|||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Dispara evento para atualizar a sidebar
|
||||||
|
window.dispatchEvent(new Event('chatUpdated'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = (content: string, files?: File[]) => {
|
const handleLoadChat = (chat: StoredChat) => {
|
||||||
|
// Carrega um chat existente do histórico
|
||||||
|
setCurrentChatId(chat.id);
|
||||||
|
setSelectedModel(chat.model);
|
||||||
|
setSystemPrompt(chat.systemPrompt);
|
||||||
|
|
||||||
|
// Converte mensagens do StoredChat para Message
|
||||||
|
const loadedMessages: Message[] = chat.messages.map(msg => ({
|
||||||
|
id: msg.id,
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
model: msg.model,
|
||||||
|
attachments: msg.attachments,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setMessages(loadedMessages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = async (content: string, files?: File[]) => {
|
||||||
|
// Adiciona mensagem do usuário na interface
|
||||||
const newMessage: Message = {
|
const newMessage: Message = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -61,31 +123,76 @@ export const ChatView = () => {
|
|||||||
setMessages((prev) => [...prev, newMessage]);
|
setMessages((prev) => [...prev, newMessage]);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Simulate AI response
|
try {
|
||||||
setTimeout(() => {
|
// Envia mensagem para a API
|
||||||
|
// Converte o nome do modelo para o ID da API
|
||||||
|
const modelId = getModelId(selectedModel);
|
||||||
|
|
||||||
|
const response = await chatService.sendMessage({
|
||||||
|
chatId: currentChatId,
|
||||||
|
message: content,
|
||||||
|
modelId: modelId,
|
||||||
|
personalidade: systemPrompt,
|
||||||
|
anexos: files,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adiciona resposta da IA na interface
|
||||||
|
if (response.success) {
|
||||||
|
// IMPORTANTE: Atualiza o chat_id com o valor retornado pela API
|
||||||
|
// Isso mantém o contexto da conversa para as próximas mensagens
|
||||||
|
if (response.chat_id && response.chat_id !== currentChatId) {
|
||||||
|
console.log(`Chat ID atualizado: ${currentChatId} → ${response.chat_id}`);
|
||||||
|
setCurrentChatId(response.chat_id);
|
||||||
|
}
|
||||||
|
|
||||||
const aiResponse: Message = {
|
const aiResponse: Message = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content:
|
content: response.response,
|
||||||
`Resposta com base na personalidade: "${systemPrompt}". Esta é uma resposta simulada que considera as instruções de sistema definidas.`,
|
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, aiResponse]);
|
setMessages((prev) => [...prev, aiResponse]);
|
||||||
|
} else {
|
||||||
|
throw new Error('Resposta inválida da API');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erro ao enviar mensagem:', error);
|
||||||
|
|
||||||
|
// Mostra toast de erro para o usuário
|
||||||
|
toast({
|
||||||
|
title: "Erro ao enviar mensagem",
|
||||||
|
description: error.message || "Não foi possível comunicar com a IA. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adiciona mensagem de erro na interface
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: "assistant",
|
||||||
|
content: "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente.",
|
||||||
|
model: selectedModel,
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMessage]);
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, 1500);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full w-full pb-16 md:pb-0">
|
||||||
{/* Chat Sidebar */}
|
{/* Chat Sidebar - Hidden on mobile */}
|
||||||
|
<div className="hidden md:block">
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
isCollapsed={isChatSidebarCollapsed}
|
isCollapsed={isChatSidebarCollapsed}
|
||||||
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChat}
|
||||||
|
onSelectChat={handleLoadChat}
|
||||||
|
currentChatId={currentChatId}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Chat Area */}
|
{/* Main Chat Area */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col w-full">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
showModelSelector={true}
|
showModelSelector={true}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
@@ -95,7 +202,7 @@ export const ChatView = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={message.id}
|
key={message.id}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChatHeader } from "@/components/ChatHeader";
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -13,56 +13,133 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { imageGenerationService, IMAGE_SIZE_OPTIONS, ImageSize } from "@/services/imageGeneration";
|
||||||
|
|
||||||
interface GeneratedImage {
|
interface GeneratedImage {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
size: string;
|
size: ImageSize;
|
||||||
timestamp: string;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageView = () => {
|
export const ImageView = () => {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [selectedSize, setSelectedSize] = useState("1024x1024");
|
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [images, setImages] = useState<GeneratedImage[]>([
|
const [images, setImages] = useState<GeneratedImage[]>([]);
|
||||||
{
|
const { toast } = useToast();
|
||||||
id: "1",
|
|
||||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
|
// Carrega histórico do localStorage ao montar o componente
|
||||||
prompt: "Paisagem futurista com cidades voadoras",
|
useEffect(() => {
|
||||||
size: "1024x1024",
|
const savedImages = localStorage.getItem('imageHistory');
|
||||||
timestamp: "Há 2 horas",
|
if (savedImages) {
|
||||||
},
|
try {
|
||||||
{
|
const parsedImages = JSON.parse(savedImages);
|
||||||
id: "2",
|
// Converte strings de data de volta para Date objects
|
||||||
url: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=512&h=512&fit=crop",
|
const imagesWithDates = parsedImages.map((img: any) => ({
|
||||||
prompt: "Robô humanoide em estilo cyberpunk",
|
...img,
|
||||||
size: "1024x1536",
|
timestamp: new Date(img.timestamp),
|
||||||
timestamp: "Há 5 horas",
|
}));
|
||||||
},
|
setImages(imagesWithDates);
|
||||||
]);
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar histórico de imagens:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
// Valida a descrição antes de enviar
|
||||||
|
const validation = imageGenerationService.validateDescription(prompt);
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast({
|
||||||
|
title: "Descrição inválida",
|
||||||
|
description: validation.error,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerate = () => {
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
// Simulate image generation
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
const newImage: GeneratedImage = {
|
// Chama o serviço de geração de imagem
|
||||||
id: Date.now().toString(),
|
const response = await imageGenerationService.generateImage({
|
||||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
|
description: prompt,
|
||||||
prompt: prompt,
|
|
||||||
size: selectedSize,
|
size: selectedSize,
|
||||||
timestamp: "Agora",
|
});
|
||||||
|
|
||||||
|
// Verifica se a geração foi bem-sucedida
|
||||||
|
if (response.success) {
|
||||||
|
// Log da URL da imagem para debug
|
||||||
|
console.log('URL da imagem gerada:', response.image_url);
|
||||||
|
|
||||||
|
const newImage: GeneratedImage = {
|
||||||
|
id: response.image_generation_id,
|
||||||
|
url: response.image_url,
|
||||||
|
prompt: response.message,
|
||||||
|
size: selectedSize,
|
||||||
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
setImages([newImage, ...images]);
|
|
||||||
setIsGenerating(false);
|
const newHistory = [newImage, ...images].slice(0, 20); // Mantém apenas as últimas 20 imagens
|
||||||
|
setImages(newHistory);
|
||||||
|
localStorage.setItem('imageHistory', JSON.stringify(newHistory));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Imagem gerada com sucesso",
|
||||||
|
description: `Tamanho: ${IMAGE_SIZE_OPTIONS[selectedSize].label}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limpa o campo de descrição após sucesso
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
}, 3000);
|
} else {
|
||||||
|
throw new Error('Erro ao gerar imagem');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erro na geração de imagem:', error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Erro na geração",
|
||||||
|
description: error.message || "Não foi possível gerar a imagem. Tente novamente.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
setImages(images.filter((img) => img.id !== id));
|
const newHistory = images.filter((img) => img.id !== id);
|
||||||
|
setImages(newHistory);
|
||||||
|
localStorage.setItem('imageHistory', JSON.stringify(newHistory));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Imagem removida",
|
||||||
|
description: "A imagem foi removida do histórico.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (image: GeneratedImage) => {
|
||||||
|
try {
|
||||||
|
await imageGenerationService.downloadImage(
|
||||||
|
image.url,
|
||||||
|
`${image.prompt.substring(0, 30)}_${image.size}.png`
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Download iniciado",
|
||||||
|
description: "A imagem está sendo baixada.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro no download",
|
||||||
|
description: "Não foi possível baixar a imagem.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredImages = images.filter((img) =>
|
const filteredImages = images.filter((img) =>
|
||||||
@@ -70,25 +147,26 @@ export const ImageView = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="max-w-5xl mx-auto p-6">
|
<div className="max-w-5xl mx-auto p-3 md:p-6">
|
||||||
<Tabs defaultValue="generate" className="space-y-6">
|
<Tabs defaultValue="generate" className="space-y-4 md:space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2 glass-effect">
|
<TabsList className="grid w-full grid-cols-2 glass-effect">
|
||||||
<TabsTrigger value="generate">Gerar Imagem</TabsTrigger>
|
<TabsTrigger value="generate" className="text-xs md:text-sm">Gerar Imagem</TabsTrigger>
|
||||||
<TabsTrigger value="history">
|
<TabsTrigger value="history" className="text-xs md:text-sm">
|
||||||
<Clock className="w-4 h-4 mr-2" />
|
<Clock className="w-3 h-3 md:w-4 md:h-4 mr-1 md:mr-2" />
|
||||||
Histórico
|
<span className="hidden md:inline">Histórico</span>
|
||||||
|
<span className="md:hidden">Hist.</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Generate Tab */}
|
{/* Generate Tab */}
|
||||||
<TabsContent value="generate" className="space-y-6">
|
<TabsContent value="generate" className="space-y-4 md:space-y-6">
|
||||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
<div className="glass-effect rounded-xl p-4 md:p-6 space-y-3 md:space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-foreground">
|
<label className="text-sm font-medium text-foreground">
|
||||||
Descreva a imagem
|
Descreva a imagem
|
||||||
@@ -101,19 +179,19 @@ export const ImageView = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-end">
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<label className="text-sm font-medium text-foreground">
|
<label className="text-sm font-medium text-foreground">
|
||||||
Tamanho
|
Tamanho
|
||||||
</label>
|
</label>
|
||||||
<Select value={selectedSize} onValueChange={setSelectedSize}>
|
<Select value={selectedSize} onValueChange={(value) => setSelectedSize(value as ImageSize)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="glass-effect bg-popover border-border z-50">
|
<SelectContent className="glass-effect bg-popover border-border z-50">
|
||||||
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
|
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
|
||||||
<SelectItem value="1024x1536">1024x1536 (Retrato)</SelectItem>
|
<SelectItem value="1024x1792">1024x1792 (Retrato)</SelectItem>
|
||||||
<SelectItem value="1536x1024">1536x1024 (Paisagem)</SelectItem>
|
<SelectItem value="1792x1024">1792x1024 (Paisagem)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,6 +233,7 @@ export const ImageView = () => {
|
|||||||
<ImageCard
|
<ImageCard
|
||||||
image={images[0]}
|
image={images[0]}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDownload={handleDownload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -181,6 +260,7 @@ export const ImageView = () => {
|
|||||||
key={image.id}
|
key={image.id}
|
||||||
image={image}
|
image={image}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDownload={handleDownload}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,29 +284,93 @@ export const ImageView = () => {
|
|||||||
interface ImageCardProps {
|
interface ImageCardProps {
|
||||||
image: GeneratedImage;
|
image: GeneratedImage;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onDownload: (image: GeneratedImage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageCard = ({ image, onDelete }: ImageCardProps) => {
|
const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
|
|
||||||
|
// Calcula tempo relativo
|
||||||
|
const getRelativeTime = (timestamp: Date) => {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - new Date(timestamp).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'Agora';
|
||||||
|
if (minutes < 60) return `Há ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||||
|
if (hours < 24) return `Há ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||||
|
return `Há ${days} ${days === 1 ? 'dia' : 'dias'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
console.warn('Erro CORS ao carregar imagem:', image.url);
|
||||||
|
setImageError(true);
|
||||||
|
setImageLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
console.log('Imagem carregada com sucesso:', image.url);
|
||||||
|
setImageLoading(false);
|
||||||
|
setImageError(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
|
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
|
||||||
<div className="relative aspect-square">
|
<div className="relative aspect-square">
|
||||||
|
{imageLoading && !imageError && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-muted">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{imageError ? (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted text-muted-foreground p-4">
|
||||||
|
<Sparkles className="w-12 h-12 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm text-center font-medium mb-1">Erro ao carregar</p>
|
||||||
|
<p className="text-xs text-center mb-2">A imagem foi gerada, mas não pode ser exibida aqui</p>
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<a
|
||||||
|
href={image.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-center bg-primary text-primary-foreground px-3 py-2 rounded-md hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Abrir imagem em nova aba
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(image.url)}
|
||||||
|
className="text-xs text-center bg-secondary text-secondary-foreground px-3 py-1 rounded-md hover:bg-secondary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Copiar URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={image.prompt}
|
alt={image.prompt}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
|
onError={handleImageError}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
|
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
|
||||||
<p className="text-sm text-white line-clamp-2">{image.prompt}</p>
|
<p className="text-sm text-white line-clamp-2">{image.prompt}</p>
|
||||||
<div className="flex items-center justify-between text-xs text-white/70">
|
<div className="flex items-center justify-between text-xs text-white/70">
|
||||||
<span>{image.size}</span>
|
<span>{IMAGE_SIZE_OPTIONS[image.size].label}</span>
|
||||||
<span>{image.timestamp}</span>
|
<span>{getRelativeTime(image.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-1 gap-1"
|
className="flex-1 gap-1"
|
||||||
|
onClick={() => onDownload(image)}
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
Baixar
|
Baixar
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Configuração de modelos de IA disponíveis
|
||||||
|
* Mapeia os nomes de exibição para os IDs usados pela API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModelConfig {
|
||||||
|
name: string; // Nome exibido na interface
|
||||||
|
id: string; // ID enviado para a API
|
||||||
|
description?: string; // Descrição opcional do modelo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista de modelos disponíveis
|
||||||
|
* NOTA: Estes dados correspondem à tabela de modelos do banco de dados.
|
||||||
|
* Futuramente será integrado com API para buscar dinamicamente.
|
||||||
|
*/
|
||||||
|
export const AVAILABLE_MODELS: ModelConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'GPT-4.1',
|
||||||
|
id: '1',
|
||||||
|
description: 'Modelo GPT-4.1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GPT-4o',
|
||||||
|
id: '2',
|
||||||
|
description: 'Modelo GPT-4o',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GPT-5 Mini',
|
||||||
|
id: '3',
|
||||||
|
description: 'Modelo GPT-5 Mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gemini 2.0 Flash',
|
||||||
|
id: '4',
|
||||||
|
description: 'Google Gemini 2.0 Flash',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Claude Sonnet 4.5',
|
||||||
|
id: '5',
|
||||||
|
description: 'Anthropic Claude Sonnet 4.5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DeepSeek V3.2 Chat',
|
||||||
|
id: '6',
|
||||||
|
description: 'DeepSeek V3.2 Chat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DeepSeek V3.2 Reasoner',
|
||||||
|
id: '7',
|
||||||
|
description: 'DeepSeek V3.2 Reasoner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gemini 2.5 Flash',
|
||||||
|
id: '8',
|
||||||
|
description: 'Google Gemini 2.5 Flash',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gemini 2.5 Flash-Lite',
|
||||||
|
id: '9',
|
||||||
|
description: 'Google Gemini 2.5 Flash-Lite',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Claude Haiku 4.5',
|
||||||
|
id: '10',
|
||||||
|
description: 'Anthropic Claude Haiku 4.5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GPT-4o Mini',
|
||||||
|
id: '11',
|
||||||
|
description: 'Modelo GPT-4o Mini',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapeia o nome do modelo para o ID da API
|
||||||
|
*
|
||||||
|
* @param modelName - Nome do modelo exibido na interface
|
||||||
|
* @returns ID do modelo para a API, ou o próprio nome se não encontrado
|
||||||
|
*/
|
||||||
|
export function getModelId(modelName: string): string {
|
||||||
|
const model = AVAILABLE_MODELS.find(m => m.name === modelName);
|
||||||
|
return model?.id || modelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapeia o ID da API para o nome de exibição
|
||||||
|
*
|
||||||
|
* @param modelId - ID do modelo na API
|
||||||
|
* @returns Nome do modelo para exibir, ou o próprio ID se não encontrado
|
||||||
|
*/
|
||||||
|
export function getModelName(modelId: string): string {
|
||||||
|
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
|
||||||
|
return model?.name || modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém a configuração completa de um modelo pelo nome
|
||||||
|
*
|
||||||
|
* @param modelName - Nome do modelo
|
||||||
|
* @returns Configuração do modelo ou undefined
|
||||||
|
*/
|
||||||
|
export function getModelConfig(modelName: string): ModelConfig | undefined {
|
||||||
|
return AVAILABLE_MODELS.find(m => m.name === modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna apenas os nomes dos modelos para uso em dropdowns
|
||||||
|
*/
|
||||||
|
export function getModelNames(): string[] {
|
||||||
|
return AVAILABLE_MODELS.map(m => m.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FUTURA INTEGRAÇÃO COM BANCO DE DADOS
|
||||||
|
*
|
||||||
|
* Quando a API estiver pronta, substituir AVAILABLE_MODELS por chamada à API:
|
||||||
|
*
|
||||||
|
* export async function fetchModelsFromDatabase(): Promise<ModelConfig[]> {
|
||||||
|
* const response = await apiService.get('/api/models');
|
||||||
|
* return response.data.map((model: any) => ({
|
||||||
|
* name: model.name,
|
||||||
|
* id: model.id.toString(),
|
||||||
|
* description: model.description || '',
|
||||||
|
* }));
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Nos componentes, usar:
|
||||||
|
* - useEffect para carregar modelos na montagem
|
||||||
|
* - useState para armazenar lista de modelos
|
||||||
|
* - Loading state durante o fetch
|
||||||
|
*/
|
||||||
+16
-4
@@ -2,10 +2,12 @@ import { useState } from "react";
|
|||||||
import { Layout } from "@/components/Layout";
|
import { Layout } from "@/components/Layout";
|
||||||
import { ChatView } from "@/components/chat/ChatView";
|
import { ChatView } from "@/components/chat/ChatView";
|
||||||
import { ImageView } from "@/components/images/ImageView";
|
import { ImageView } from "@/components/images/ImageView";
|
||||||
import { AudioView } from "@/components/audio/AudioView";
|
import { TranscriptionView } from "@/components/audio/TranscriptionView";
|
||||||
|
import { GenerationView } from "@/components/audio/GenerationView";
|
||||||
import { BotView } from "@/components/bots/BotView";
|
import { BotView } from "@/components/bots/BotView";
|
||||||
import { BotChat } from "@/components/bots/BotChat";
|
import { BotChat } from "@/components/bots/BotChat";
|
||||||
import { AgentView } from "@/components/agent/AgentView";
|
import { AgentView } from "@/components/agent/AgentView";
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
interface Bot {
|
interface Bot {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,7 +17,7 @@ interface Bot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const [activeView, setActiveView] = useState<"chat" | "images" | "audio" | "bots" | "agent">("chat");
|
const [activeView, setActiveView] = useState<"chat" | "images" | "transcription" | "generation" | "bots" | "agent">("chat");
|
||||||
const [activeBotChat, setActiveBotChat] = useState<Bot | null>(null);
|
const [activeBotChat, setActiveBotChat] = useState<Bot | null>(null);
|
||||||
|
|
||||||
const handleStartBotChat = (bot: Bot) => {
|
const handleStartBotChat = (bot: Bot) => {
|
||||||
@@ -26,11 +28,15 @@ const Index = () => {
|
|||||||
setActiveBotChat(null);
|
setActiveBotChat(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (<Routes>
|
||||||
|
<Route path="" element={<Navigate to={`/404`} replace />} />
|
||||||
|
<Route path="codex">
|
||||||
|
<Route path="" element={
|
||||||
<Layout activeTab={activeView} onTabChange={setActiveView}>
|
<Layout activeTab={activeView} onTabChange={setActiveView}>
|
||||||
{activeView === "chat" && <ChatView />}
|
{activeView === "chat" && <ChatView />}
|
||||||
{activeView === "images" && <ImageView />}
|
{activeView === "images" && <ImageView />}
|
||||||
{activeView === "audio" && <AudioView />}
|
{activeView === "transcription" && <TranscriptionView />}
|
||||||
|
{activeView === "generation" && <GenerationView />}
|
||||||
{activeView === "bots" && (
|
{activeView === "bots" && (
|
||||||
activeBotChat ? (
|
activeBotChat ? (
|
||||||
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
|
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
|
||||||
@@ -40,6 +46,12 @@ const Index = () => {
|
|||||||
)}
|
)}
|
||||||
{activeView === "agent" && <AgentView />}
|
{activeView === "agent" && <AgentView />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to={`/404`} replace />} />
|
||||||
|
</Routes>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import FullScreenLoader from "@/components/FullScreenLoader";
|
||||||
|
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||||
|
import { RedirectService } from "./RedirectService";
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function Redirect() {
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
let { gsikey } = useParams();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let error: number = 0;
|
||||||
|
sessionStorage.setItem('gsikey', gsikey ?? '0');
|
||||||
|
RedirectService.loadTransferArea(parseInt(gsikey ?? '0')).then((response) => {
|
||||||
|
//console.log(response);
|
||||||
|
error = response.data.errorCode;
|
||||||
|
//msg = response.data.errorMessage;
|
||||||
|
if(response.data.errorCode == 0){
|
||||||
|
sessionStorage.setItem('TRANSFER_AREA',JSON.stringify(response.data.areTrs));
|
||||||
|
sessionStorage.setItem('usuarioLogado',JSON.stringify({
|
||||||
|
token: response.data.jwt.token,
|
||||||
|
refreshToken: response.data.jwt.refreshToken
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// else if(error !== 1401){
|
||||||
|
// navigate('/',{replace: true});
|
||||||
|
// }
|
||||||
|
})
|
||||||
|
.catch((_err) => {
|
||||||
|
navigate('/404',{replace: true});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if(error == 0 || error === 1401){
|
||||||
|
navigate(`/${GlobalFunctions.getRedirectComponent()}`,{replace: true});
|
||||||
|
}
|
||||||
|
// else if(error === 1401){
|
||||||
|
// navigate({pathname: '/',search:`?p=${window.btoa(msg)}`},{replace: true});
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullScreenLoader show={true} text="Carregando...Aguarde..." />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||||
|
import axios from "axios";
|
||||||
|
const endPoint: string = 'v1/login/';
|
||||||
|
|
||||||
|
export class RedirectService{
|
||||||
|
|
||||||
|
public static loadTransferArea(gsikey: number): Promise<any>{
|
||||||
|
return axios.post(`${import.meta.env.VITE_BASE_URL_OPEN_CODEX_APIs}${endPoint}login_by_gsikey`,{
|
||||||
|
"gsikey" : gsikey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async validaAcessoApp(urlDestino: string): Promise<any>{
|
||||||
|
//let token = await GlobalFunctions.getToken();
|
||||||
|
let usuLogado = GlobalFunctions.getUsuarioLogado();
|
||||||
|
let usuCod: string = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioCodigo);
|
||||||
|
let estCod: string = usuLogado.EID;
|
||||||
|
|
||||||
|
let urlDestinoPost: string = urlDestino;//urlDestino.trim().length < 1 ? GlobalFunctions.getTransferProperty(TransferAreaProperties.urlDestino) : urlDestino;
|
||||||
|
if(urlDestinoPost.trim().length < 1){
|
||||||
|
try {
|
||||||
|
let pathSplit = window.location.pathname.split('/');
|
||||||
|
|
||||||
|
urlDestinoPost = `/${pathSplit[pathSplit.length-2]}/${pathSplit[pathSplit.length-1]}`;
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//const heads = { headers: {'Content-Type':'application/json; charset=utf-8','Authorization': `Bearer ${token}`} };
|
||||||
|
const heads = { headers: {'Content-Type':'application/json; charset=utf-8'} };
|
||||||
|
return axios.post(`${import.meta.env.VITE_BASE_URL_HGTX_CORE_API}v1/aplicativos/validar_acesso_app?usuCod=${usuCod}&estCod=${estCod}`,{
|
||||||
|
"urlDestino": urlDestinoPost
|
||||||
|
},heads);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuração centralizada da API
|
||||||
|
* Todas as chamadas de API devem usar este serviço para garantir
|
||||||
|
* autenticação e configuração consistente
|
||||||
|
*/
|
||||||
|
class ApiService {
|
||||||
|
private axiosInstance: AxiosInstance;
|
||||||
|
private apiKey: string;
|
||||||
|
private baseURL: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Busca configurações das variáveis de ambiente
|
||||||
|
this.apiKey = import.meta.env.VITE_API_KEY || '';
|
||||||
|
this.baseURL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
|
|
||||||
|
// Validação das variáveis de ambiente
|
||||||
|
if (!this.apiKey) {
|
||||||
|
console.error('VITE_API_KEY não configurada no arquivo .env');
|
||||||
|
}
|
||||||
|
if (!this.baseURL) {
|
||||||
|
console.error('VITE_API_BASE_URL não configurada no arquivo .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cria instância do Axios com configurações padrão
|
||||||
|
this.axiosInstance = axios.create({
|
||||||
|
baseURL: this.baseURL,
|
||||||
|
timeout: 60000, // 60 segundos para upload de arquivos
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interceptor para adicionar API Key em todas as requisições
|
||||||
|
this.axiosInstance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// Adiciona a API Key no header de todas as requisições
|
||||||
|
if (this.apiKey) {
|
||||||
|
config.headers['apikey'] = this.apiKey;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Interceptor de resposta para tratamento centralizado de erros
|
||||||
|
this.axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
// Log de erro para debug
|
||||||
|
console.error('API Error:', {
|
||||||
|
message: error.message,
|
||||||
|
status: error.response?.status,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retorna erro formatado
|
||||||
|
return Promise.reject({
|
||||||
|
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
|
||||||
|
status: error.response?.status,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requisição GET
|
||||||
|
*/
|
||||||
|
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.get<T>(url, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requisição POST
|
||||||
|
*/
|
||||||
|
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.post<T>(url, data, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requisição POST com FormData (para upload de arquivos)
|
||||||
|
*/
|
||||||
|
async postFormData<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.post<T>(url, formData, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...config?.headers,
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requisição PUT
|
||||||
|
*/
|
||||||
|
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.put<T>(url, data, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requisição DELETE
|
||||||
|
*/
|
||||||
|
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.delete<T>(url, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna a URL base configurada
|
||||||
|
*/
|
||||||
|
getBaseURL(): string {
|
||||||
|
return this.baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna a instância do Axios (uso avançado)
|
||||||
|
*/
|
||||||
|
getInstance(): AxiosInstance {
|
||||||
|
return this.axiosInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporta instância única (Singleton)
|
||||||
|
export const apiService = new ApiService();
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
|
import { apiService } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de vozes disponíveis para geração de áudio
|
||||||
|
*/
|
||||||
|
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para a resposta da API de geração de áudio
|
||||||
|
*/
|
||||||
|
export interface AudioGenerationResponse {
|
||||||
|
success: boolean;
|
||||||
|
audio_url: string; // URL do áudio gerado
|
||||||
|
audio_generation_id: string;
|
||||||
|
message: string; // Texto que foi convertido em áudio
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para os dados necessários para geração de áudio
|
||||||
|
*/
|
||||||
|
export interface AudioGenerationRequest {
|
||||||
|
message: string;
|
||||||
|
voice: VoiceType;
|
||||||
|
userEmail?: string;
|
||||||
|
estabelecimentoId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informações sobre cada tipo de voz disponível
|
||||||
|
*/
|
||||||
|
export const VOICE_OPTIONS = {
|
||||||
|
alloy: {
|
||||||
|
label: "Alloy",
|
||||||
|
gender: "Masculina",
|
||||||
|
style: "Neutra, equilibrada, tom corporativo",
|
||||||
|
description: "Boa para tutoriais e comunicações institucionais."
|
||||||
|
},
|
||||||
|
echo: {
|
||||||
|
label: "Echo",
|
||||||
|
gender: "Masculina",
|
||||||
|
style: "Forte e profissional, mais grave",
|
||||||
|
description: "Ideal para voz de autoridade ou locução firme."
|
||||||
|
},
|
||||||
|
fable: {
|
||||||
|
label: "Fable",
|
||||||
|
gender: "Feminina",
|
||||||
|
style: "Narrativa, calorosa e envolvente",
|
||||||
|
description: "Ótima para storytelling e áudios empáticos."
|
||||||
|
},
|
||||||
|
onyx: {
|
||||||
|
label: "Onyx",
|
||||||
|
gender: "Masculina",
|
||||||
|
style: "Grave, autoritária, impactante",
|
||||||
|
description: "Excelente para trailers, mensagens sérias ou institucionais."
|
||||||
|
},
|
||||||
|
nova: {
|
||||||
|
label: "Nova",
|
||||||
|
gender: "Feminina",
|
||||||
|
style: "Brilhante, animada, energética",
|
||||||
|
description: "Boa para vídeos curtos, marketing ou conteúdos leves."
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
label: "Shimmer",
|
||||||
|
gender: "Feminina",
|
||||||
|
style: "Suave, otimista, clara",
|
||||||
|
description: "Boa para mensagens acolhedoras, explicações e IA conversacional."
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço de geração de áudio (Text-to-Speech)
|
||||||
|
*/
|
||||||
|
class AudioGenerationService {
|
||||||
|
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera um arquivo de áudio a partir de texto
|
||||||
|
*
|
||||||
|
* @param request - Dados da requisição (texto, voz, email, estabelecimento)
|
||||||
|
* @returns Promise com a resposta da API
|
||||||
|
*/
|
||||||
|
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
|
||||||
|
const { message, voice, userEmail, estabelecimentoId } = request;
|
||||||
|
|
||||||
|
// Usa valores do .env se não forem fornecidos
|
||||||
|
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
||||||
|
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
|
||||||
|
|
||||||
|
// Valida o texto
|
||||||
|
if (!message || message.trim().length === 0) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: 'O texto não pode estar vazio',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida a voz
|
||||||
|
if (!this.isValidVoice(voice)) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log para debug (remover em produção se necessário)
|
||||||
|
console.log('Gerando áudio:', {
|
||||||
|
messageLength: message.length,
|
||||||
|
voice,
|
||||||
|
userEmail: email,
|
||||||
|
estabelecimentoId: estabId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Faz a requisição usando o serviço de API
|
||||||
|
const response = await apiService.post<AudioGenerationResponse>(
|
||||||
|
this.AUDIO_GENERATION_ENDPOINT,
|
||||||
|
{
|
||||||
|
estabelecimento_id: estabId,
|
||||||
|
user_email: email,
|
||||||
|
message: message,
|
||||||
|
voice: voice,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Trata erros específicos
|
||||||
|
console.error('Erro na geração de áudio:', error);
|
||||||
|
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Erro ao gerar áudio',
|
||||||
|
status: error.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida se a voz selecionada é suportada
|
||||||
|
*
|
||||||
|
* @param voice - Voz a ser validada
|
||||||
|
* @returns true se a voz é válida
|
||||||
|
*/
|
||||||
|
isValidVoice(voice: string): voice is VoiceType {
|
||||||
|
return Object.keys(VOICE_OPTIONS).includes(voice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações sobre uma voz específica
|
||||||
|
*
|
||||||
|
* @param voice - Tipo de voz
|
||||||
|
* @returns Informações da voz
|
||||||
|
*/
|
||||||
|
getVoiceInfo(voice: VoiceType) {
|
||||||
|
return VOICE_OPTIONS[voice];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista todas as vozes disponíveis
|
||||||
|
*
|
||||||
|
* @returns Array com todas as opções de voz
|
||||||
|
*/
|
||||||
|
getAllVoices() {
|
||||||
|
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
|
||||||
|
value: key as VoiceType,
|
||||||
|
...info,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida o texto para geração de áudio
|
||||||
|
*
|
||||||
|
* @param text - Texto a ser validado
|
||||||
|
* @param maxLength - Comprimento máximo (padrão: 4096 caracteres)
|
||||||
|
* @returns Objeto com resultado da validação
|
||||||
|
*/
|
||||||
|
validateText(text: string, maxLength: number = 4096): { valid: boolean; error?: string } {
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'O texto não pode estar vazio',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > maxLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `O texto é muito longo. Máximo: ${maxLength} caracteres`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporta instância única (Singleton)
|
||||||
|
export const audioGenerationService = new AudioGenerationService();
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
|
import { apiService } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para a resposta da API de chat
|
||||||
|
*/
|
||||||
|
export interface ChatResponse {
|
||||||
|
success: boolean;
|
||||||
|
response: string; // Mensagem da IA
|
||||||
|
chat_id: string; // ID do chat retornado pela API (importante para manter contexto)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para os dados necessários para enviar mensagem
|
||||||
|
*/
|
||||||
|
export interface ChatMessageRequest {
|
||||||
|
chatId: string;
|
||||||
|
message: string;
|
||||||
|
modelId: string;
|
||||||
|
personalidade?: string;
|
||||||
|
anexos?: File[]; // Array de até 5 arquivos
|
||||||
|
userEmail?: string;
|
||||||
|
estabelecimentoId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para armazenamento local de conversas
|
||||||
|
* Preparando para futura integração com banco de dados
|
||||||
|
*/
|
||||||
|
export interface StoredChat {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
model: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
messages: Array<{
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
model?: string;
|
||||||
|
timestamp: Date;
|
||||||
|
attachments?: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para armazenamento de pastas
|
||||||
|
* Preparando para futura integração com banco de dados
|
||||||
|
*/
|
||||||
|
export interface StoredFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: Date;
|
||||||
|
chatIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço de chat com IA
|
||||||
|
*/
|
||||||
|
class ChatService {
|
||||||
|
private readonly CHAT_ENDPOINT = '/webhook/codex/message';
|
||||||
|
private readonly STORAGE_KEY_CHATS = 'hgtx_chats';
|
||||||
|
private readonly STORAGE_KEY_FOLDERS = 'hgtx_folders';
|
||||||
|
|
||||||
|
// Formatos de arquivo permitidos (atualmente)
|
||||||
|
private readonly ALLOWED_FILE_TYPES = {
|
||||||
|
// Formatos ativos
|
||||||
|
'application/pdf': { ext: '.pdf', label: 'PDF' },
|
||||||
|
'image/png': { ext: '.png', label: 'PNG' },
|
||||||
|
'image/jpeg': { ext: '.jpg, .jpeg', label: 'JPEG' },
|
||||||
|
'image/webp': { ext: '.webp', label: 'WebP' },
|
||||||
|
|
||||||
|
// Formatos futuros (desabilitados por enquanto)
|
||||||
|
// 'text/csv': { ext: '.csv', label: 'CSV' },
|
||||||
|
// 'text/plain': { ext: '.txt', label: 'TXT' },
|
||||||
|
// 'application/json': { ext: '.json', label: 'JSON' },
|
||||||
|
// 'application/vnd.ms-excel': { ext: '.xls', label: 'XLS' },
|
||||||
|
// 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { ext: '.xlsx', label: 'XLSX' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Número máximo de anexos permitidos
|
||||||
|
private readonly MAX_ATTACHMENTS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera um chat_id único e seguro usando hash
|
||||||
|
* Formato: timestamp + random + hash
|
||||||
|
*
|
||||||
|
* NOTA: Esta função está mantida para compatibilidade e uso no localStorage,
|
||||||
|
* mas para comunicação com a API, o fluxo correto é:
|
||||||
|
* 1. Enviar chat_id: "0" na primeira mensagem
|
||||||
|
* 2. API retorna o chat_id real
|
||||||
|
* 3. Usar o chat_id retornado nas próximas mensagens
|
||||||
|
*
|
||||||
|
* @returns string - ID único para o chat (uso local)
|
||||||
|
*/
|
||||||
|
generateChatId(): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const randomPart = Math.random().toString(36).substring(2, 15);
|
||||||
|
const randomPart2 = Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
|
// Combina timestamp e partes aleatórias para criar ID único
|
||||||
|
const chatId = `chat_${timestamp}_${randomPart}${randomPart2}`;
|
||||||
|
|
||||||
|
return chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envia mensagem para a API de chat
|
||||||
|
*
|
||||||
|
* @param request - Dados da requisição (mensagem, modelo, anexos, etc)
|
||||||
|
* @returns Promise com a resposta da API
|
||||||
|
*/
|
||||||
|
async sendMessage(request: ChatMessageRequest): Promise<ChatResponse> {
|
||||||
|
const {
|
||||||
|
chatId,
|
||||||
|
message,
|
||||||
|
modelId,
|
||||||
|
personalidade,
|
||||||
|
anexos,
|
||||||
|
userEmail,
|
||||||
|
estabelecimentoId
|
||||||
|
} = request;
|
||||||
|
|
||||||
|
// Usa valores do .env se não forem fornecidos
|
||||||
|
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
||||||
|
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
|
||||||
|
|
||||||
|
// Validações
|
||||||
|
if (!chatId) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: 'ID do chat é obrigatório',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message || message.trim().length === 0) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: 'A mensagem não pode estar vazia',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelId) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: 'Modelo da IA é obrigatório',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida número de anexos
|
||||||
|
if (anexos && anexos.length > 5) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: 'Máximo de 5 arquivos anexos permitidos',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cria FormData para envio multipart
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('estabelecimento_id', estabId.toString());
|
||||||
|
formData.append('chat_id', chatId);
|
||||||
|
formData.append('user_email', email);
|
||||||
|
formData.append('model_id', modelId);
|
||||||
|
formData.append('message', message);
|
||||||
|
|
||||||
|
// Adiciona personalidade se fornecida
|
||||||
|
if (personalidade && personalidade.trim().length > 0) {
|
||||||
|
formData.append('personalidade', personalidade);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adiciona anexos (máximo 5)
|
||||||
|
if (anexos && anexos.length > 0) {
|
||||||
|
anexos.forEach((file, index) => {
|
||||||
|
if (index < 5) {
|
||||||
|
formData.append(`anexo${index + 1}`, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log para debug (remover em produção se necessário)
|
||||||
|
console.log('Enviando mensagem:', {
|
||||||
|
chatId,
|
||||||
|
messageLength: message.length,
|
||||||
|
modelId,
|
||||||
|
hasPersonalidade: !!personalidade,
|
||||||
|
attachmentsCount: anexos?.length || 0,
|
||||||
|
userEmail: email,
|
||||||
|
estabelecimentoId: estabId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Faz a requisição usando o serviço de API
|
||||||
|
const response = await apiService.postFormData<ChatResponse>(
|
||||||
|
this.CHAT_ENDPOINT,
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Trata erros específicos
|
||||||
|
console.error('Erro ao enviar mensagem:', error);
|
||||||
|
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Erro ao comunicar com a IA',
|
||||||
|
status: error.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva ou atualiza um chat no localStorage
|
||||||
|
* Preparado para futura migração para banco de dados
|
||||||
|
*
|
||||||
|
* @param chat - Chat a ser salvo
|
||||||
|
*/
|
||||||
|
saveChat(chat: StoredChat): void {
|
||||||
|
try {
|
||||||
|
const chats = this.getAllChats();
|
||||||
|
const existingIndex = chats.findIndex(c => c.id === chat.id);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
chats[existingIndex] = {
|
||||||
|
...chat,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
chats.push(chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(chats));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar chat:', error);
|
||||||
|
throw new Error('Não foi possível salvar o chat');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca um chat específico por ID
|
||||||
|
*
|
||||||
|
* @param chatId - ID do chat
|
||||||
|
* @returns Chat encontrado ou undefined
|
||||||
|
*/
|
||||||
|
getChat(chatId: string): StoredChat | undefined {
|
||||||
|
const chats = this.getAllChats();
|
||||||
|
return chats.find(c => c.id === chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna todos os chats salvos
|
||||||
|
*
|
||||||
|
* @returns Array de chats
|
||||||
|
*/
|
||||||
|
getAllChats(): StoredChat[] {
|
||||||
|
try {
|
||||||
|
const chatsJson = localStorage.getItem(this.STORAGE_KEY_CHATS);
|
||||||
|
if (!chatsJson) return [];
|
||||||
|
|
||||||
|
const chats = JSON.parse(chatsJson);
|
||||||
|
|
||||||
|
// Converte strings de data para objetos Date
|
||||||
|
return chats.map((chat: any) => ({
|
||||||
|
...chat,
|
||||||
|
createdAt: new Date(chat.createdAt),
|
||||||
|
updatedAt: new Date(chat.updatedAt),
|
||||||
|
messages: chat.messages.map((msg: any) => ({
|
||||||
|
...msg,
|
||||||
|
timestamp: new Date(msg.timestamp),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar chats:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleta um chat
|
||||||
|
*
|
||||||
|
* @param chatId - ID do chat a ser deletado
|
||||||
|
*/
|
||||||
|
deleteChat(chatId: string): void {
|
||||||
|
try {
|
||||||
|
const chats = this.getAllChats();
|
||||||
|
const filteredChats = chats.filter(c => c.id !== chatId);
|
||||||
|
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(filteredChats));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar chat:', error);
|
||||||
|
throw new Error('Não foi possível deletar o chat');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva uma pasta no localStorage
|
||||||
|
*
|
||||||
|
* @param folder - Pasta a ser salva
|
||||||
|
*/
|
||||||
|
saveFolder(folder: StoredFolder): void {
|
||||||
|
try {
|
||||||
|
const folders = this.getAllFolders();
|
||||||
|
const existingIndex = folders.findIndex(f => f.id === folder.id);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
folders[existingIndex] = folder;
|
||||||
|
} else {
|
||||||
|
folders.push(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(folders));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar pasta:', error);
|
||||||
|
throw new Error('Não foi possível salvar a pasta');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna todas as pastas salvas
|
||||||
|
*
|
||||||
|
* @returns Array de pastas
|
||||||
|
*/
|
||||||
|
getAllFolders(): StoredFolder[] {
|
||||||
|
try {
|
||||||
|
const foldersJson = localStorage.getItem(this.STORAGE_KEY_FOLDERS);
|
||||||
|
if (!foldersJson) return [];
|
||||||
|
|
||||||
|
const folders = JSON.parse(foldersJson);
|
||||||
|
|
||||||
|
// Converte strings de data para objetos Date
|
||||||
|
return folders.map((folder: any) => ({
|
||||||
|
...folder,
|
||||||
|
createdAt: new Date(folder.createdAt),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar pastas:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleta uma pasta
|
||||||
|
*
|
||||||
|
* @param folderId - ID da pasta a ser deletada
|
||||||
|
*/
|
||||||
|
deleteFolder(folderId: string): void {
|
||||||
|
try {
|
||||||
|
const folders = this.getAllFolders();
|
||||||
|
const filteredFolders = folders.filter(f => f.id !== folderId);
|
||||||
|
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(filteredFolders));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar pasta:', error);
|
||||||
|
throw new Error('Não foi possível deletar a pasta');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera título automático para o chat baseado na primeira mensagem
|
||||||
|
*
|
||||||
|
* @param message - Primeira mensagem do usuário
|
||||||
|
* @param maxLength - Comprimento máximo do título
|
||||||
|
* @returns Título gerado
|
||||||
|
*/
|
||||||
|
generateChatTitle(message: string, maxLength: number = 50): string {
|
||||||
|
if (!message || message.trim().length === 0) {
|
||||||
|
return 'Nova Conversa';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = message.trim();
|
||||||
|
if (trimmed.length <= maxLength) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.substring(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpa todas as conversas (usar com cuidado)
|
||||||
|
*/
|
||||||
|
clearAllChats(): void {
|
||||||
|
localStorage.removeItem(this.STORAGE_KEY_CHATS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpa todas as pastas (usar com cuidado)
|
||||||
|
*/
|
||||||
|
clearAllFolders(): void {
|
||||||
|
localStorage.removeItem(this.STORAGE_KEY_FOLDERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida anexos antes de enviar para a API
|
||||||
|
*
|
||||||
|
* @param files - Array de arquivos a serem validados
|
||||||
|
* @returns Objeto com resultado da validação
|
||||||
|
*/
|
||||||
|
validateAttachments(files: File[]): { valid: boolean; error?: string } {
|
||||||
|
// Valida número de anexos
|
||||||
|
if (files.length > this.MAX_ATTACHMENTS) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Máximo de ${this.MAX_ATTACHMENTS} anexos permitidos. Você selecionou ${files.length}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida cada arquivo
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const fileType = file.type;
|
||||||
|
const fileName = file.name;
|
||||||
|
|
||||||
|
// Verifica se o tipo MIME é permitido
|
||||||
|
if (!this.ALLOWED_FILE_TYPES[fileType as keyof typeof this.ALLOWED_FILE_TYPES]) {
|
||||||
|
// Tenta validar pela extensão também
|
||||||
|
const extension = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
|
||||||
|
const isExtensionValid = Object.values(this.ALLOWED_FILE_TYPES).some(
|
||||||
|
type => type.ext.includes(extension)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isExtensionValid) {
|
||||||
|
const allowedFormats = Object.values(this.ALLOWED_FILE_TYPES)
|
||||||
|
.map(t => t.label)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Arquivo "${fileName}" não é permitido. Formatos aceitos: ${allowedFormats}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna lista de formatos de arquivo permitidos
|
||||||
|
*
|
||||||
|
* @returns Array com informações dos formatos
|
||||||
|
*/
|
||||||
|
getAllowedFileTypes() {
|
||||||
|
return Object.entries(this.ALLOWED_FILE_TYPES).map(([mimeType, info]) => ({
|
||||||
|
mimeType,
|
||||||
|
...info,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna string formatada com formatos permitidos (para exibição)
|
||||||
|
*
|
||||||
|
* @returns String formatada (ex: "PDF, PNG, JPEG, WebP")
|
||||||
|
*/
|
||||||
|
getAllowedFileTypesLabel(): string {
|
||||||
|
return Object.values(this.ALLOWED_FILE_TYPES)
|
||||||
|
.map(t => t.label)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna string com extensões para usar no input file accept
|
||||||
|
*
|
||||||
|
* @returns String formatada (ex: ".pdf,.png,.jpg,.jpeg,.webp")
|
||||||
|
*/
|
||||||
|
getAllowedFileExtensions(): string {
|
||||||
|
return Object.values(this.ALLOWED_FILE_TYPES)
|
||||||
|
.map(t => t.ext)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna número máximo de anexos permitidos
|
||||||
|
*/
|
||||||
|
getMaxAttachments(): number {
|
||||||
|
return this.MAX_ATTACHMENTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporta instância única (Singleton)
|
||||||
|
export const chatService = new ChatService();
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
|
import { apiService } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tamanhos de imagem disponíveis
|
||||||
|
*/
|
||||||
|
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para a resposta da API de geração de imagens
|
||||||
|
*/
|
||||||
|
export interface ImageGenerationResponse {
|
||||||
|
success: boolean;
|
||||||
|
image_url: string; // URL da imagem gerada
|
||||||
|
image_generation_id: string;
|
||||||
|
message: string; // Descrição original
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para os dados necessários para geração de imagem
|
||||||
|
*/
|
||||||
|
export interface ImageGenerationRequest {
|
||||||
|
description: string;
|
||||||
|
size: ImageSize;
|
||||||
|
userEmail?: string;
|
||||||
|
estabelecimentoId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informações sobre cada tamanho de imagem disponível
|
||||||
|
*/
|
||||||
|
export const IMAGE_SIZE_OPTIONS = {
|
||||||
|
'1024x1024': {
|
||||||
|
label: 'Quadrado',
|
||||||
|
dimensions: '1024x1024',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
description: 'Ideal para avatares, ícones e posts em redes sociais',
|
||||||
|
},
|
||||||
|
'1024x1792': {
|
||||||
|
label: 'Retrato',
|
||||||
|
dimensions: '1024x1792',
|
||||||
|
aspectRatio: '9:16',
|
||||||
|
description: 'Perfeito para stories, wallpapers verticais e reels',
|
||||||
|
},
|
||||||
|
'1792x1024': {
|
||||||
|
label: 'Paisagem',
|
||||||
|
dimensions: '1792x1024',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
description: 'Ótimo para banners, capas e thumbnails de vídeos',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço de geração de imagens com IA
|
||||||
|
*/
|
||||||
|
class ImageGenerationService {
|
||||||
|
private readonly IMAGE_GENERATION_ENDPOINT = '/webhook/codex/image_generator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera uma imagem a partir de uma descrição em texto
|
||||||
|
*
|
||||||
|
* @param request - Dados da requisição (descrição, tamanho, email, estabelecimento)
|
||||||
|
* @returns Promise com a resposta da API
|
||||||
|
*/
|
||||||
|
async generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResponse> {
|
||||||
|
const { description, size, userEmail, estabelecimentoId } = request;
|
||||||
|
|
||||||
|
// Usa valores do .env se não forem fornecidos
|
||||||
|
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
||||||
|
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
|
||||||
|
|
||||||
|
// Valida a descrição
|
||||||
|
if (!description || description.trim().length === 0) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: 'A descrição não pode estar vazia',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida o tamanho
|
||||||
|
if (!this.isValidSize(size)) {
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: `Tamanho inválido. Opções disponíveis: ${Object.keys(IMAGE_SIZE_OPTIONS).join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log para debug (remover em produção se necessário)
|
||||||
|
console.log('Gerando imagem:', {
|
||||||
|
descriptionLength: description.length,
|
||||||
|
size,
|
||||||
|
userEmail: email,
|
||||||
|
estabelecimentoId: estabId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Faz a requisição usando o serviço de API
|
||||||
|
// Nota: O campo no body é "estabelecito_id" (com typo na API)
|
||||||
|
const response = await apiService.post<ImageGenerationResponse>(
|
||||||
|
this.IMAGE_GENERATION_ENDPOINT,
|
||||||
|
{
|
||||||
|
estabelecimento_id: estabId, // Mantém o typo da API original
|
||||||
|
user_email: email,
|
||||||
|
description: description,
|
||||||
|
size: size,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Trata erros específicos
|
||||||
|
console.error('Erro na geração de imagem:', error);
|
||||||
|
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Erro ao gerar imagem',
|
||||||
|
status: error.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida se o tamanho selecionado é suportado
|
||||||
|
*
|
||||||
|
* @param size - Tamanho a ser validado
|
||||||
|
* @returns true se o tamanho é válido
|
||||||
|
*/
|
||||||
|
isValidSize(size: string): size is ImageSize {
|
||||||
|
return Object.keys(IMAGE_SIZE_OPTIONS).includes(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações sobre um tamanho específico
|
||||||
|
*
|
||||||
|
* @param size - Tamanho da imagem
|
||||||
|
* @returns Informações do tamanho
|
||||||
|
*/
|
||||||
|
getSizeInfo(size: ImageSize) {
|
||||||
|
return IMAGE_SIZE_OPTIONS[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista todos os tamanhos disponíveis
|
||||||
|
*
|
||||||
|
* @returns Array com todas as opções de tamanho
|
||||||
|
*/
|
||||||
|
getAllSizes() {
|
||||||
|
return Object.entries(IMAGE_SIZE_OPTIONS).map(([key, info]) => ({
|
||||||
|
value: key as ImageSize,
|
||||||
|
...info,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida a descrição para geração de imagem
|
||||||
|
*
|
||||||
|
* @param description - Descrição a ser validada
|
||||||
|
* @param minLength - Comprimento mínimo (padrão: 3 caracteres)
|
||||||
|
* @param maxLength - Comprimento máximo (padrão: 1000 caracteres)
|
||||||
|
* @returns Objeto com resultado da validação
|
||||||
|
*/
|
||||||
|
validateDescription(
|
||||||
|
description: string,
|
||||||
|
minLength: number = 3,
|
||||||
|
maxLength: number = 1000
|
||||||
|
): { valid: boolean; error?: string } {
|
||||||
|
if (!description || description.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'A descrição não pode estar vazia',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.trim().length < minLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `A descrição deve ter pelo menos ${minLength} caracteres`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.length > maxLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `A descrição é muito longa. Máximo: ${maxLength} caracteres`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faz download de uma imagem gerada
|
||||||
|
*
|
||||||
|
* @param imageUrl - URL da imagem
|
||||||
|
* @param filename - Nome do arquivo (opcional)
|
||||||
|
*/
|
||||||
|
async downloadImage(imageUrl: string, filename?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename || `imagem_${Date.now()}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao baixar imagem:', error);
|
||||||
|
throw new Error('Não foi possível baixar a imagem');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporta instância única (Singleton)
|
||||||
|
export const imageGenerationService = new ImageGenerationService();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Exporta todos os serviços de API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { apiService } from './api';
|
||||||
|
export { transcriptionService } from './transcription';
|
||||||
|
export { audioGenerationService, VOICE_OPTIONS } from './audioGeneration';
|
||||||
|
export { imageGenerationService, IMAGE_SIZE_OPTIONS } from './imageGeneration';
|
||||||
|
export type { TranscriptionResponse, TranscriptionRequest } from './transcription';
|
||||||
|
export type { AudioGenerationResponse, AudioGenerationRequest, VoiceType } from './audioGeneration';
|
||||||
|
export type { ImageGenerationResponse, ImageGenerationRequest, ImageSize } from './imageGeneration';
|
||||||
|
export type { ApiResponse, UserConfig, ApiError } from './types';
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
|
import { apiService } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para a resposta da API de transcrição
|
||||||
|
*/
|
||||||
|
export interface TranscriptionResponse {
|
||||||
|
success: boolean;
|
||||||
|
transcription_id: string;
|
||||||
|
audio_url: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para os dados necessários para transcrição
|
||||||
|
*/
|
||||||
|
export interface TranscriptionRequest {
|
||||||
|
audioFile: File;
|
||||||
|
userEmail?: string;
|
||||||
|
estabelecimentoId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço de transcrição de áudio
|
||||||
|
*/
|
||||||
|
class TranscriptionService {
|
||||||
|
private readonly TRANSCRIPTION_ENDPOINT = '/webhook/codex/transcrever_audio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcreve um arquivo de áudio
|
||||||
|
*
|
||||||
|
* @param request - Dados da requisição (arquivo, email, estabelecimento)
|
||||||
|
* @returns Promise com a resposta da API
|
||||||
|
*/
|
||||||
|
async transcribeAudio(request: TranscriptionRequest): Promise<TranscriptionResponse> {
|
||||||
|
const { audioFile, userEmail, estabelecimentoId } = request;
|
||||||
|
|
||||||
|
// Cria FormData para envio multipart
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('data', audioFile);
|
||||||
|
|
||||||
|
// Usa valores do .env se não forem fornecidos
|
||||||
|
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
||||||
|
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
|
||||||
|
|
||||||
|
formData.append('user_email', email);
|
||||||
|
formData.append('estabelecimento_id', estabId.toString());
|
||||||
|
|
||||||
|
// Log para debug (remover em produção se necessário)
|
||||||
|
console.log('Enviando transcrição:', {
|
||||||
|
fileName: audioFile.name,
|
||||||
|
fileSize: audioFile.size,
|
||||||
|
fileType: audioFile.type,
|
||||||
|
userEmail: email,
|
||||||
|
estabelecimentoId: estabId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Faz a requisição usando o serviço de API
|
||||||
|
const response = await apiService.postFormData<TranscriptionResponse>(
|
||||||
|
this.TRANSCRIPTION_ENDPOINT,
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Trata erros específicos
|
||||||
|
console.error('Erro na transcrição:', error);
|
||||||
|
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Erro ao transcrever áudio',
|
||||||
|
status: error.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida se o arquivo de áudio é suportado
|
||||||
|
*
|
||||||
|
* @param file - Arquivo a ser validado
|
||||||
|
* @param maxSizeMB - Tamanho máximo em MB (padrão: 25MB)
|
||||||
|
* @returns Objeto com resultado da validação
|
||||||
|
*/
|
||||||
|
validateAudioFile(file: File, maxSizeMB: number = 25): { valid: boolean; error?: string } {
|
||||||
|
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
|
||||||
|
const MAX_FILE_SIZE = maxSizeMB * 1024 * 1024;
|
||||||
|
|
||||||
|
// Valida tamanho
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida formato
|
||||||
|
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
if (!fileExtension || !SUPPORTED_FORMATS.includes(fileExtension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Formato não suportado. Formatos aceitos: ${SUPPORTED_FORMATS.join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporta instância única (Singleton)
|
||||||
|
export const transcriptionService = new TranscriptionService();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Tipos compartilhados para as APIs
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resposta padrão de sucesso/erro da API
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuração de usuário para requisições
|
||||||
|
*/
|
||||||
|
export interface UserConfig {
|
||||||
|
userEmail?: string;
|
||||||
|
estabelecimentoId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resposta de erro da API
|
||||||
|
*/
|
||||||
|
export interface ApiError {
|
||||||
|
success: false;
|
||||||
|
message: string;
|
||||||
|
status?: number;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
Vendored
+14
@@ -1 +1,15 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_API_KEY: string
|
||||||
|
readonly VITE_USER_EMAIL: string
|
||||||
|
readonly VITE_ESTABELECIMENTO_ID: string
|
||||||
|
readonly VITE_BASE_URL_OPEN_CODEX_APIs: string
|
||||||
|
readonly VITE_BASE_URL_HGTX_CORE_API: string
|
||||||
|
readonly VITE_BASE_URL_HGTX_CORE: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user