atualizacoes codex

This commit is contained in:
Vitex Tecnologia
2026-03-20 00:02:12 -03:00
parent 6bf7e7d42e
commit 6537e2dd53
10 changed files with 232 additions and 67 deletions
+8 -1
View File
@@ -40,6 +40,7 @@ export function AreasView() {
const [loadingList, setLoadingList] = useState(true); const [loadingList, setLoadingList] = useState(true);
const totalPages = Math.max(1, totalPaginas); const totalPages = Math.max(1, totalPaginas);
const isFiltering = filterNome.trim().length > 0;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -262,7 +263,7 @@ export function AreasView() {
</div> </div>
<div className="flex-1 overflow-auto p-3 md:p-6"> <div className="flex-1 overflow-auto p-3 md:p-6">
{!loadingList && areasList.length === 0 && totalRegistros === 0 ? ( {!loadingList && totalRegistros === 0 && !isFiltering ? (
<Card className="max-w-2xl mx-auto mt-12"> <Card className="max-w-2xl mx-auto mt-12">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -297,6 +298,12 @@ export function AreasView() {
Carregando... Carregando...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : totalRegistros === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground py-8">
Nenhum resultado para o filtro {filterNome.trim() ? `"${filterNome.trim()}"` : "atual"}.
</TableCell>
</TableRow>
) : ( ) : (
areasList.map((item) => ( areasList.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
+18 -11
View File
@@ -14,6 +14,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { audioGenerationService } from "@/services/audioGeneration";
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']; const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
@@ -186,19 +187,23 @@ export const AudioView = () => {
}, 2000); }, 2000);
}; };
const handleDownloadAudio = (audio: GeneratedAudio) => { const handleDownloadAudio = async (audio: GeneratedAudio) => {
// In real implementation, download the actual audio file const filename = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
const a = document.createElement('a'); try {
a.href = audio.audioUrl; await audioGenerationService.downloadAudioFile(audio.audioUrl, filename);
a.download = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast({ toast({
title: "Download iniciado", title: "Download iniciado",
description: "O arquivo de áudio está sendo baixado.", description: "O arquivo de áudio está sendo baixado.",
}); });
} catch (error: unknown) {
const message =
error && typeof error === "object" && "message" in error ? String((error as { message: string }).message) : "Não foi possível baixar o áudio.";
toast({
title: "Erro ao baixar",
description: message,
variant: "destructive",
});
}
}; };
const handleDeleteAudio = (audioId: string) => { const handleDeleteAudio = (audioId: string) => {
@@ -482,10 +487,11 @@ export const AudioView = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-1" className="gap-1"
onClick={() => handleDownloadAudio(generatedAudio)} onClick={() => void handleDownloadAudio(generatedAudio)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
Baixar MP3 Baixar MP3
@@ -658,9 +664,10 @@ export const AudioView = () => {
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleDownloadAudio(item)} onClick={() => void handleDownloadAudio(item)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
</Button> </Button>
+17 -10
View File
@@ -134,18 +134,23 @@ export const GenerationView = () => {
} }
}; };
const handleDownloadAudio = (audio: AudioRecord) => { const handleDownloadAudio = async (audio: AudioRecord) => {
const a = document.createElement('a'); const filename = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
a.href = audio.audio_url; try {
a.download = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`; await audioGenerationService.downloadAudioFile(audio.audio_url, filename);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast({ toast({
title: "Download iniciado", title: "Download iniciado",
description: "O arquivo de áudio está sendo baixado.", description: "O arquivo de áudio está sendo baixado.",
}); });
} catch (error: unknown) {
const message =
error && typeof error === "object" && "message" in error ? String((error as { message: string }).message) : "Não foi possível baixar o áudio.";
toast({
title: "Erro ao baixar",
description: message,
variant: "destructive",
});
}
}; };
const handleDeleteAudio = async (audioId: string) => { const handleDeleteAudio = async (audioId: string) => {
@@ -290,10 +295,11 @@ export const GenerationView = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-1" className="gap-1"
onClick={() => handleDownloadAudio(lastGeneratedAudio)} onClick={() => void handleDownloadAudio(lastGeneratedAudio)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
Baixar Baixar
@@ -385,9 +391,10 @@ export const GenerationView = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => handleDownloadAudio(audio)} onClick={() => void handleDownloadAudio(audio)}
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
+11 -6
View File
@@ -1,4 +1,5 @@
import { Bot, User, Copy, File } from "lucide-react"; import { Bot, User, Copy, File } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useState } from "react"; import { useState } from "react";
@@ -53,7 +54,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
</div> </div>
)} )}
<div className={`flex-1 max-w-3xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}> <div className={`flex-1 max-w-4xl 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,15 +74,19 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
)} )}
<div <div
className={`inline-block px-3 md:px-4 py-2 md:py-3 rounded-xl ${ className={`inline-block max-w-[90%] px-3 md:px-4 py-2 md:py-3 rounded-xl break-words ${
isAssistant isAssistant
? "bg-card border border-border" ? "bg-card border border-border text-foreground"
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground" : "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
}`} }`}
> >
<p className={`text-sm md:text-base leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}> {isAssistant ? (
{content} <div className="prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-p:leading-relaxed">
</p> <ReactMarkdown>{content}</ReactMarkdown>
</div>
) : (
<p className="text-sm md:text-base leading-relaxed text-white">{content}</p>
)}
</div> </div>
{showActions && ( {showActions && (
@@ -133,8 +133,9 @@ export function ParecerJuridicoDetailView() {
new Date(a.created_at.replace(" ", "T")).getTime() - new Date(a.created_at.replace(" ", "T")).getTime() -
new Date(b.created_at.replace(" ", "T")).getTime() new Date(b.created_at.replace(" ", "T")).getTime()
); );
const nonEmptyMessages = sorted.filter((m) => (m.content ?? "").trim().length > 0);
setMessages( setMessages(
sorted.map((m) => ({ nonEmptyMessages.map((m) => ({
id: m.id, id: m.id,
role: m.role, role: m.role,
content: m.content, content: m.content,
@@ -155,18 +155,32 @@ export function ParecerJuridicoFormView() {
<div className="flex-1 overflow-auto p-3 md:p-6"> <div className="flex-1 overflow-auto p-3 md:p-6">
<div className="max-w-4xl mx-auto flex flex-col gap-6"> <div className="max-w-4xl mx-auto flex flex-col gap-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="titulo-parecer">Título do parecer</Label> <Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
<span>Título do parecer</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Input <Input
id="titulo-parecer" id="titulo-parecer"
value={tituloParecer} value={tituloParecer}
onChange={(e) => setTituloParecer(e.target.value)} onChange={(e) => setTituloParecer(e.target.value)}
placeholder="Ex.: Parecer sobre contrato de prestação de serviços" placeholder="Ex.: Parecer sobre contrato de prestação de serviços"
className="w-full" className="w-full"
required
aria-required="true"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="area">Área</Label> <Label htmlFor="area" className="flex flex-wrap items-baseline gap-1">
<span>Área</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Select value={areaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}> <Select value={areaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
<SelectTrigger id="area" className="w-full"> <SelectTrigger id="area" className="w-full">
<SelectValue <SelectValue
@@ -190,7 +204,7 @@ export function ParecerJuridicoFormView() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="prompt">Prompt</Label> <Label htmlFor="prompt">Prompt da base</Label>
<Select <Select
value={promptId || "none"} value={promptId || "none"}
onValueChange={(v) => setPromptId(v === "none" ? "" : v)} onValueChange={(v) => setPromptId(v === "none" ? "" : v)}
@@ -221,29 +235,45 @@ export function ParecerJuridicoFormView() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="conteudo-prompt">Conteúdo do prompt (editável)</Label> <Label htmlFor="conteudo-prompt" className="flex flex-wrap items-baseline gap-1">
<span>Conteúdo do prompt</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Textarea <Textarea
id="conteudo-prompt" id="conteudo-prompt"
value={conteudoPrompt} value={conteudoPrompt}
onChange={(e) => setConteudoPrompt(e.target.value)} onChange={(e) => setConteudoPrompt(e.target.value)}
placeholder="Selecione um prompt acima ou edite o conteúdo aqui. Este valor será enviado na geração do parecer." placeholder="Selecione um prompt acima ou edite o conteúdo aqui."
className="min-h-[200px] resize-y w-full" className="min-h-[200px] resize-y w-full"
required
aria-required="true"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="instrucao">Instrução</Label> <Label htmlFor="instrucao" className="flex flex-wrap items-baseline gap-1">
<span>Instrução</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Textarea <Textarea
id="instrucao" id="instrucao"
value={instrucao} value={instrucao}
onChange={(e) => setInstrucao(e.target.value)} onChange={(e) => setInstrucao(e.target.value)}
placeholder="Instruções adicionais para a geração do parecer..." placeholder="Instruções adicionais para a geração do parecer..."
className="min-h-[100px] resize-y w-full" className="min-h-[100px] resize-y w-full"
required
aria-required="true"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="anexo">Anexo (máx. 1 arquivo)</Label> <Label htmlFor="anexo">Anexo</Label>
<p className="text-xs text-muted-foreground">Formatos permitidos: PDF, TXT.</p> <p className="text-xs text-muted-foreground">Formatos permitidos: PDF, TXT.</p>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2 w-full min-h-10 rounded-md border border-input bg-background px-3 py-2"> <div className="flex items-center gap-2 w-full min-h-10 rounded-md border border-input bg-background px-3 py-2">
+4 -2
View File
@@ -183,10 +183,12 @@ export const PromptsView = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<Button onClick={() => navigate("/codex/prompts/novo")}> <Button onClick={() => navigate("/codex/prompts/novo")}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Novo prompt Novo prompt
</Button> </Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@@ -207,10 +209,10 @@ export const PromptsView = () => {
Carregando... Carregando...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : promptsList.length === 0 ? ( ) : totalRegistros === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground py-10 text-base"> <TableCell colSpan={3} className="text-center text-muted-foreground py-10 text-base">
Não resultados. {hasActiveFilters ? "Nenhum prompt encontrado para os filtros selecionados." : "Não há resultados."}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
+18 -5
View File
@@ -135,21 +135,34 @@ class AreasService {
throw new Error("ID da área é obrigatório"); throw new Error("ID da área é obrigatório");
} }
const idEnc = encodeURIComponent(id.trim());
const body: EditarAreaBody = { const body: EditarAreaBody = {
nome: nome.trim(), nome: nome.trim(),
descricao: (descricao ?? "").trim(), descricao: (descricao ?? "").trim(),
}; };
const response = await apiService.put<EditarAreaSuccessResponse | EditarAreaErrorResponse>( /** Mesmo prefixo de webhook que o fluxo n8n (UUID) usado nos endpoints de "parecer/areas". */
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/area/${id}`, const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/parecer/area/${idEnc}`;
const response = await apiService.put<
EditarAreaSuccessResponse | EditarAreaErrorResponse | (EditarAreaSuccessResponse | EditarAreaErrorResponse)[]
>(
url,
body body
); );
if (response.data.success === false) { const raw = response.data;
throw new Error((response.data as EditarAreaErrorResponse).message ?? "Erro ao editar área"); const data = Array.isArray(raw) ? raw[0] : raw;
if (!data || typeof data !== "object" || !("success" in data)) {
throw new Error("Resposta inválida ao editar área");
} }
return response.data as EditarAreaSuccessResponse; if (data.success === false) {
throw new Error((data as EditarAreaErrorResponse).message ?? "Erro ao editar área");
}
return data as EditarAreaSuccessResponse;
} }
async deletar(id: string): Promise<DeletarAreaSuccessResponse> { async deletar(id: string): Promise<DeletarAreaSuccessResponse> {
+83
View File
@@ -180,6 +180,89 @@ class AudioGenerationService {
this.toApiError(error, 'Erro ao deletar áudio'); this.toApiError(error, 'Erro ao deletar áudio');
} }
} }
/**
* Baixa o arquivo no navegador (sem navegar para a URL).
* Usa blob local + <a download> — necessário porque links cross-origin ignoram `download` e abrem a URL.
*/
async downloadAudioFile(audioUrl: string, filename?: string): Promise<void> {
const urlTrim = audioUrl?.trim();
if (!urlTrim) {
throw new Error('URL do áudio inválida');
}
const safeName = (filename || `audio_${Date.now()}.mp3`).replace(/[/\\?%*:|"<>]/g, '_');
let sameOrigin = false;
try {
const u = new URL(urlTrim, typeof window !== 'undefined' ? window.location.href : undefined);
sameOrigin = typeof window !== 'undefined' && u.origin === window.location.origin;
} catch {
sameOrigin = false;
}
const fetchBlob = async (): Promise<Blob> => {
const response = await fetch(urlTrim, {
mode: 'cors',
credentials: sameOrigin ? 'include' : 'omit',
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`Falha ao baixar (HTTP ${response.status})`);
}
return response.blob();
};
const xhrBlob = (): Promise<Blob> =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', urlTrim, true);
xhr.responseType = 'blob';
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject(new Error(`Falha ao baixar (HTTP ${xhr.status})`));
}
};
xhr.onerror = () => reject(new Error('Falha de rede ao baixar o áudio'));
xhr.send();
});
let blob: Blob;
try {
blob = await fetchBlob();
} catch (e1) {
try {
blob = await xhrBlob();
} catch (e2) {
console.error('downloadAudioFile:', e1, e2);
throw new Error(
'Não foi possível baixar o áudio. Se o arquivo estiver em outro domínio, é preciso CORS liberando GET para esta origem.'
);
}
}
const needsMimeFix =
!blob.type ||
blob.type === 'application/octet-stream' ||
blob.type === 'text/html';
const typedBlob = needsMimeFix ? new Blob([blob], { type: 'audio/mpeg' }) : blob;
const objectUrl = URL.createObjectURL(typedBlob);
try {
const a = document.createElement('a');
a.href = objectUrl;
a.download = safeName.endsWith('.mp3') ? safeName : `${safeName}.mp3`;
a.style.display = 'none';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
} }
export const audioGenerationService = new AudioGenerationService(); export const audioGenerationService = new AudioGenerationService();
+18 -8
View File
@@ -151,25 +151,35 @@ class PromptsService {
throw new Error("ID do prompt é obrigatório"); throw new Error("ID do prompt é obrigatório");
} }
const response = await apiService.put<EditarPromptSuccessResponse | EditarPromptErrorResponse>( const idEnc = encodeURIComponent(id.trim());
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/prompt/editar/${id}`, /** Mesmo prefixo de webhook n8n usado em edição de área (`areas.editar`). */
{ const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/parecer/prompt/${idEnc}`;
const response = await apiService.put<
EditarPromptSuccessResponse | EditarPromptErrorResponse | (EditarPromptSuccessResponse | EditarPromptErrorResponse)[]
>(url, {
titulo: (body.titulo ?? "").trim(), titulo: (body.titulo ?? "").trim(),
descricao: (body.descricao ?? "").trim(), descricao: (body.descricao ?? "").trim(),
area_id: body.area_id.trim(), area_id: body.area_id.trim(),
conteudo: (body.conteudo ?? "").trim(), conteudo: (body.conteudo ?? "").trim(),
} });
);
if (response.data.success === false) { const raw = response.data;
const err = response.data as EditarPromptErrorResponse; const data = Array.isArray(raw) ? raw[0] : raw;
if (!data || typeof data !== "object" || !("success" in data)) {
throw new Error("Resposta inválida ao editar prompt");
}
if (data.success === false) {
const err = data as EditarPromptErrorResponse;
const msg = err.missing_fields?.length const msg = err.missing_fields?.length
? `Preencha: ${err.missing_fields.join(", ")}` ? `Preencha: ${err.missing_fields.join(", ")}`
: "Erro ao editar prompt."; : "Erro ao editar prompt.";
throw new Error(msg); throw new Error(msg);
} }
return response.data as EditarPromptSuccessResponse; return data as EditarPromptSuccessResponse;
} }
} }