feat: add AI chat for repports
This commit is contained in:
@ -296,6 +296,60 @@ func (p *Pipeline) callProviderForReport(ctx context.Context, excerpt, question
|
|||||||
return provider.Summarize(ctx, prompt, GenOptions{Think: true, NumCtx: 16384})
|
return provider.Summarize(ctx, prompt, GenOptions{Think: true, NumCtx: 16384})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateReportMessageAsync génère une réponse de conversation en arrière-plan.
|
||||||
|
// history contient tous les messages précédents (user + assistant), dans l'ordre.
|
||||||
|
func (p *Pipeline) GenerateReportMessageAsync(messageID string, report *models.Report, history []models.ReportMessage, mgr *ReportManager) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
mgr.Register(messageID, cancel)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
defer mgr.Remove(messageID)
|
||||||
|
|
||||||
|
answer, err := p.callProviderForConversation(ctx, report, history)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = p.repo.UpdateReportMessage(messageID, "error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = p.repo.UpdateReportMessage(messageID, "done", answer)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) callProviderForConversation(ctx context.Context, report *models.Report, history []models.ReportMessage) (string, error) {
|
||||||
|
provider, _, err := p.buildProviderForRole("report")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Tu es un assistant financier expert engagé dans une conversation avec un trader.\n\n")
|
||||||
|
sb.WriteString("## Contexte initial\n")
|
||||||
|
sb.WriteString("Extraits sélectionnés :\n")
|
||||||
|
sb.WriteString(report.ContextExcerpt)
|
||||||
|
sb.WriteString("\n\nQuestion initiale : ")
|
||||||
|
sb.WriteString(report.Question)
|
||||||
|
sb.WriteString("\nRéponse initiale : ")
|
||||||
|
sb.WriteString(report.Answer)
|
||||||
|
sb.WriteString("\n\n## Suite de la conversation\n")
|
||||||
|
|
||||||
|
for _, msg := range history {
|
||||||
|
if msg.Role == "user" {
|
||||||
|
sb.WriteString("Trader : ")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("Assistant : ")
|
||||||
|
}
|
||||||
|
sb.WriteString(msg.Content)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nRéponds en français, de façon précise et orientée trading.")
|
||||||
|
|
||||||
|
return provider.Summarize(ctx, sb.String(), GenOptions{Think: true, NumCtx: 16384})
|
||||||
|
}
|
||||||
|
|
||||||
func buildPrompt(systemPrompt string, symbols []string, articles []models.Article, tz string) string {
|
func buildPrompt(systemPrompt string, symbols []string, articles []models.Article, tz string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(systemPrompt)
|
sb.WriteString(systemPrompt)
|
||||||
|
|||||||
@ -69,6 +69,79 @@ func (h *Handler) GetGeneratingStatus(c *gin.Context) {
|
|||||||
httputil.OK(c, gin.H{"generating": h.pipeline.IsGenerating()})
|
httputil.OK(c, gin.H{"generating": h.pipeline.IsGenerating()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListReportMessages(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
reportID := c.Param("id")
|
||||||
|
|
||||||
|
report, err := h.repo.GetReport(reportID, userID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.InternalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if report == nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := h.repo.ListReportMessages(reportID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.InternalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.OK(c, msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateReportMessage(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
reportID := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httputil.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := h.repo.GetReport(reportID, userID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.InternalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if report == nil || report.Status != "done" {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer l'historique pour le contexte IA
|
||||||
|
history, err := h.repo.ListReportMessages(reportID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.InternalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persister le message utilisateur
|
||||||
|
userMsg, err := h.repo.CreateReportMessage(reportID, "user", req.Content, "done")
|
||||||
|
if err != nil {
|
||||||
|
httputil.InternalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le message assistant en attente
|
||||||
|
assistantMsg, err := h.repo.CreateReportMessage(reportID, "assistant", "", "generating")
|
||||||
|
if err != nil {
|
||||||
|
httputil.InternalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter le message utilisateur à l'historique avant de lancer la génération
|
||||||
|
history = append(history, *userMsg)
|
||||||
|
|
||||||
|
h.pipeline.GenerateReportMessageAsync(assistantMsg.ID, report, history, h.reportManager)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, assistantMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func buildExcerptContext(excerpts []string) string {
|
func buildExcerptContext(excerpts []string) string {
|
||||||
if len(excerpts) == 1 {
|
if len(excerpts) == 1 {
|
||||||
return excerpts[0]
|
return excerpts[0]
|
||||||
|
|||||||
@ -49,6 +49,8 @@ func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine {
|
|||||||
authed.GET("/reports", h.ListReports)
|
authed.GET("/reports", h.ListReports)
|
||||||
authed.POST("/reports", h.CreateReport)
|
authed.POST("/reports", h.CreateReport)
|
||||||
authed.DELETE("/reports/:id", h.DeleteReport)
|
authed.DELETE("/reports/:id", h.DeleteReport)
|
||||||
|
authed.GET("/reports/:id/messages", h.ListReportMessages)
|
||||||
|
authed.POST("/reports/:id/messages", h.CreateReportMessage)
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
admin := authed.Group("/admin")
|
admin := authed.Group("/admin")
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS report_messages;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE report_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(10) NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
status VARCHAR(10) NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'generating', 'error')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@ -116,3 +116,12 @@ type Report struct {
|
|||||||
ErrorMsg string `json:"error_msg"`
|
ErrorMsg string `json:"error_msg"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReportMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ReportID string `json:"report_id"`
|
||||||
|
Role string `json:"role"` // user | assistant
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"` // done | generating | error
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|||||||
@ -666,3 +666,55 @@ func (r *Repository) DeleteReport(id, userID string) error {
|
|||||||
_, err := r.db.Exec(`DELETE FROM reports WHERE id=$1 AND user_id=$2`, id, userID)
|
_, err := r.db.Exec(`DELETE FROM reports WHERE id=$1 AND user_id=$2`, id, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetReport(id, userID string) (*Report, error) {
|
||||||
|
rep := &Report{}
|
||||||
|
err := r.db.QueryRow(`
|
||||||
|
SELECT id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at
|
||||||
|
FROM reports WHERE id=$1 AND user_id=$2`, id, userID,
|
||||||
|
).Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return rep, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Report messages ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (r *Repository) CreateReportMessage(reportID, role, content, status string) (*ReportMessage, error) {
|
||||||
|
msg := &ReportMessage{}
|
||||||
|
err := r.db.QueryRow(`
|
||||||
|
INSERT INTO report_messages (report_id, role, content, status)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, report_id, role, content, status, created_at`,
|
||||||
|
reportID, role, content, status,
|
||||||
|
).Scan(&msg.ID, &msg.ReportID, &msg.Role, &msg.Content, &msg.Status, &msg.CreatedAt)
|
||||||
|
return msg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdateReportMessage(id, status, content string) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE report_messages SET status=$1, content=$2 WHERE id=$3`,
|
||||||
|
status, content, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListReportMessages(reportID string) ([]ReportMessage, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, report_id, role, content, status, created_at
|
||||||
|
FROM report_messages WHERE report_id=$1
|
||||||
|
ORDER BY created_at ASC`, reportID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var msgs []ReportMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m ReportMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.ReportID, &m.Role, &m.Content, &m.Status, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgs = append(msgs, m)
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -12,9 +12,21 @@ export interface Report {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReportMessage {
|
||||||
|
id: string
|
||||||
|
report_id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
status: 'done' | 'generating' | 'error'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export const reportsApi = {
|
export const reportsApi = {
|
||||||
list: () => api.get<Report[]>('/reports'),
|
list: () => api.get<Report[]>('/reports'),
|
||||||
create: (data: { summary_id?: string; excerpts: string[]; question: string }) =>
|
create: (data: { summary_id?: string; excerpts: string[]; question: string }) =>
|
||||||
api.post<Report>('/reports', data),
|
api.post<Report>('/reports', data),
|
||||||
delete: (id: string) => api.delete<void>(`/reports/${id}`),
|
delete: (id: string) => api.delete<void>(`/reports/${id}`),
|
||||||
|
listMessages: (reportId: string) => api.get<ReportMessage[]>(`/reports/${reportId}/messages`),
|
||||||
|
createMessage: (reportId: string, content: string) =>
|
||||||
|
api.post<ReportMessage>(`/reports/${reportId}/messages`, { content }),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react'
|
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle, Send } from 'lucide-react'
|
||||||
import { reportsApi, type Report } from '@/api/reports'
|
import { reportsApi, type Report, type ReportMessage } from '@/api/reports'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Markdown } from '@/components/ui/markdown'
|
import { Markdown } from '@/components/ui/markdown'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
@ -21,6 +22,126 @@ function StatusBadge({ status }: { status: Report['status'] }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ msg }: { msg: ReportMessage }) {
|
||||||
|
const isUser = msg.role === 'user'
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||||
|
isUser
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted'
|
||||||
|
}`}>
|
||||||
|
{msg.status === 'generating' ? (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span className="text-xs">Génération…</span>
|
||||||
|
</div>
|
||||||
|
) : msg.status === 'error' ? (
|
||||||
|
<span className="text-destructive text-xs">{msg.content || 'Erreur lors de la génération.'}</span>
|
||||||
|
) : isUser ? (
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
) : (
|
||||||
|
<Markdown content={msg.content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportChat({ report }: { report: Report }) {
|
||||||
|
const [messages, setMessages] = useState<ReportMessage[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const loadMessages = useCallback(async () => {
|
||||||
|
const msgs = await reportsApi.listMessages(report.id)
|
||||||
|
setMessages(msgs ?? [])
|
||||||
|
}, [report.id])
|
||||||
|
|
||||||
|
useEffect(() => { loadMessages() }, [loadMessages])
|
||||||
|
|
||||||
|
// Poll tant qu'un message assistant est en cours de génération
|
||||||
|
useEffect(() => {
|
||||||
|
const hasGenerating = messages.some(m => m.status === 'generating')
|
||||||
|
if (!hasGenerating) return
|
||||||
|
const interval = setInterval(loadMessages, 2000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [messages, loadMessages])
|
||||||
|
|
||||||
|
// Scroll vers le bas à chaque nouveau message
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const content = input.trim()
|
||||||
|
if (!content || sending) return
|
||||||
|
setInput('')
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
// Optimistic: ajouter le message user immédiatement
|
||||||
|
const optimisticUser: ReportMessage = {
|
||||||
|
id: `tmp-${Date.now()}`,
|
||||||
|
report_id: report.id,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
status: 'done',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
const optimisticAssistant: ReportMessage = {
|
||||||
|
id: `tmp-assistant-${Date.now()}`,
|
||||||
|
report_id: report.id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'generating',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, optimisticUser, optimisticAssistant])
|
||||||
|
await reportsApi.createMessage(report.id, content)
|
||||||
|
await loadMessages()
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGenerating = messages.some(m => m.status === 'generating')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t pt-3 space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Conversation</p>
|
||||||
|
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{messages.map(msg => (
|
||||||
|
<MessageBubble key={msg.id} msg={msg} />
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && send()}
|
||||||
|
placeholder="Posez une question sur cette réponse…"
|
||||||
|
disabled={hasGenerating || sending}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={send}
|
||||||
|
disabled={!input.trim() || hasGenerating || sending}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Reports() {
|
export function Reports() {
|
||||||
const [reports, setReports] = useState<Report[]>([])
|
const [reports, setReports] = useState<Report[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -101,7 +222,7 @@ export function Reports() {
|
|||||||
{/* Question */}
|
{/* Question */}
|
||||||
<p className="text-sm font-medium">{r.question}</p>
|
<p className="text-sm font-medium">{r.question}</p>
|
||||||
|
|
||||||
{/* Réponse */}
|
{/* Réponse initiale */}
|
||||||
{r.status === 'generating' && (
|
{r.status === 'generating' && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
@ -118,6 +239,9 @@ export function Reports() {
|
|||||||
Erreur : {r.error_msg || 'Une erreur est survenue lors de la génération.'}
|
Erreur : {r.error_msg || 'Une erreur est survenue lors de la génération.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Chat de suivi (uniquement quand le rapport est terminé) */}
|
||||||
|
{r.status === 'done' && <ReportChat report={r} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"}
|
{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/reports.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/markdown.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Reports.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Schedule.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user