feat: add claudecode with oauth to AI providers
This commit is contained in:
28
frontend/src/components/ui/markdown.tsx
Normal file
28
frontend/src/components/ui/markdown.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
|
||||
function renderInline(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/g)
|
||||
return parts.map((part, i) =>
|
||||
part.startsWith('**') && part.endsWith('**')
|
||||
? <strong key={i}>{part.slice(2, -2)}</strong>
|
||||
: part
|
||||
)
|
||||
}
|
||||
|
||||
export function Markdown({ content, className }: { content: string; className?: string }) {
|
||||
const lines = content.split('\n')
|
||||
return (
|
||||
<div className={`space-y-1 text-sm leading-relaxed select-text ${className ?? ''}`}>
|
||||
{lines.map((line, i) => {
|
||||
if (line.startsWith('##### ')) return <h5 key={i} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mt-3">{renderInline(line.slice(6))}</h5>
|
||||
if (line.startsWith('#### ')) return <h4 key={i} className="text-sm font-semibold mt-3">{renderInline(line.slice(5))}</h4>
|
||||
if (line.startsWith('### ')) return <h3 key={i} className="text-sm font-semibold text-primary mt-4">{renderInline(line.slice(4))}</h3>
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-base font-bold mt-5 first:mt-0 border-b pb-1">{renderInline(line.slice(3))}</h2>
|
||||
if (line.startsWith('# ')) return <h1 key={i} className="text-lg font-bold mt-5 first:mt-0">{renderInline(line.slice(2))}</h1>
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) return <li key={i} className="ml-4 text-muted-foreground list-disc">{renderInline(line.slice(2))}</li>
|
||||
if (line.trim() === '') return <div key={i} className="h-2" />
|
||||
return <p key={i} className="text-muted-foreground">{renderInline(line)}</p>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { summariesApi, type Summary } from '@/api/summaries'
|
||||
import { reportsApi } from '@/api/reports'
|
||||
import { assetsApi, type Asset } from '@/api/assets'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
@ -135,31 +136,8 @@ function ContextPanel({
|
||||
|
||||
// ── Summary content renderer ────────────────────────────────────────────────
|
||||
|
||||
function renderInline(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/g)
|
||||
return parts.map((part, i) =>
|
||||
part.startsWith('**') && part.endsWith('**')
|
||||
? <strong key={i}>{part.slice(2, -2)}</strong>
|
||||
: part
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryContent({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
return (
|
||||
<div className="space-y-1 text-sm leading-relaxed select-text">
|
||||
{lines.map((line, i) => {
|
||||
if (line.startsWith('##### ')) return <h5 key={i} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mt-3">{renderInline(line.slice(6))}</h5>
|
||||
if (line.startsWith('#### ')) return <h4 key={i} className="text-sm font-semibold mt-3">{renderInline(line.slice(5))}</h4>
|
||||
if (line.startsWith('### ')) return <h3 key={i} className="text-sm font-semibold text-primary mt-4">{renderInline(line.slice(4))}</h3>
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-base font-bold mt-5 first:mt-0 border-b pb-1">{renderInline(line.slice(3))}</h2>
|
||||
if (line.startsWith('# ')) return <h1 key={i} className="text-lg font-bold mt-5 first:mt-0">{renderInline(line.slice(2))}</h1>
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) return <li key={i} className="ml-4 text-muted-foreground list-disc">{renderInline(line.slice(2))}</li>
|
||||
if (line.trim() === '') return <div key={i} className="h-2" />
|
||||
return <p key={i} className="text-muted-foreground">{renderInline(line)}</p>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
return <Markdown content={content} />
|
||||
}
|
||||
|
||||
// ── Dashboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react'
|
||||
import { reportsApi, type Report } from '@/api/reports'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@ -108,8 +109,8 @@ export function Reports() {
|
||||
</div>
|
||||
)}
|
||||
{r.status === 'done' && (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed border-t pt-3">
|
||||
{r.answer}
|
||||
<div className="border-t pt-3">
|
||||
<Markdown content={r.answer} />
|
||||
</div>
|
||||
)}
|
||||
{r.status === 'error' && (
|
||||
|
||||
@ -9,7 +9,7 @@ import { Select } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama'] as const
|
||||
const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama', 'claudecode'] as const
|
||||
|
||||
const CUSTOM_MODELS_KEY = 'ollama_custom_models'
|
||||
|
||||
@ -223,9 +223,15 @@ function CloudModelPicker({ value, providerName, apiKey, endpoint, onChange }: {
|
||||
const [models, setModels] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const noKeyNeeded = providerName === 'claudecode'
|
||||
|
||||
useEffect(() => {
|
||||
if (noKeyNeeded) loadModels()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [providerName])
|
||||
|
||||
async function loadModels() {
|
||||
if (!apiKey) { setLoadError('Renseigne la clé API d\'abord'); return }
|
||||
if (!noKeyNeeded && !apiKey) { setLoadError('Renseigne la clé API d\'abord'); return }
|
||||
setLoading(true); setLoadError('')
|
||||
try {
|
||||
const list = await adminApi.probeModels({ name: providerName, api_key: apiKey, endpoint })
|
||||
@ -456,6 +462,7 @@ export function AIProviders() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const isOllamaForm = form.name === 'ollama'
|
||||
const isClaudeCodeForm = form.name === 'claudecode'
|
||||
|
||||
const loadOllamaModels = useCallback(async (): Promise<OllamaModelInfo[]> => {
|
||||
try {
|
||||
@ -562,7 +569,7 @@ export function AIProviders() {
|
||||
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
{!isOllamaForm && (
|
||||
{!isOllamaForm && !isClaudeCodeForm && (
|
||||
<div className="space-y-1">
|
||||
<Label>Clé API {editId && <span className="text-muted-foreground text-xs">(vide = conserver)</span>}</Label>
|
||||
<Input type="password" placeholder="sk-…" value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
|
||||
@ -609,7 +616,7 @@ export function AIProviders() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold capitalize text-sm">{p.name}</span>
|
||||
{p.is_active && <Badge variant="default" className="text-xs">Défaut</Badge>}
|
||||
{!p.has_key && p.name !== 'ollama' && <Badge variant="outline" className="text-yellow-500 border-yellow-500 text-xs">Sans clé</Badge>}
|
||||
{!p.has_key && p.name !== 'ollama' && p.name !== 'claudecode' && <Badge variant="outline" className="text-yellow-500 border-yellow-500 text-xs">Sans clé</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex gap-3 mt-0.5">
|
||||
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
|
||||
|
||||
Reference in New Issue
Block a user