feat: add claudecode with oauth to AI providers

This commit is contained in:
2026-04-21 16:27:58 +02:00
parent 985768f400
commit f2bb88f040
12 changed files with 267 additions and 32 deletions

View 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>
)
}

View File

@ -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 ───────────────────────────────────────────────────────────────

View File

@ -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' && (

View File

@ -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>}