351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import axios from 'axios'
|
||
import { SyncOutlined } from '@ant-design/icons'
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Card,
|
||
Collapse,
|
||
Form,
|
||
Input,
|
||
Space,
|
||
Spin,
|
||
Tabs,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd'
|
||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||
|
||
const { Title, Text } = Typography
|
||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||
const PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY = 'playground-provider-status'
|
||
|
||
interface AIProviderStatus {
|
||
provider: string
|
||
api?: string | null
|
||
enabled: boolean
|
||
configured: boolean
|
||
model?: string | null
|
||
base_url?: string | null
|
||
}
|
||
|
||
interface AIContentBlock {
|
||
type: string
|
||
text?: string | null
|
||
thinking?: string | null
|
||
}
|
||
|
||
interface SituationalAnalysisResponse {
|
||
provider: string
|
||
model: string
|
||
content: string
|
||
content_blocks: AIContentBlock[]
|
||
text_blocks: string[]
|
||
thinking_blocks: string[]
|
||
raw_response: Record<string, unknown>
|
||
}
|
||
|
||
interface PlaygroundFormValues {
|
||
title: string
|
||
objective: string
|
||
observations?: string
|
||
constraints?: string
|
||
}
|
||
|
||
function splitLines(value?: string) {
|
||
return (value || '')
|
||
.split('\n')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function Playground() {
|
||
const [form] = Form.useForm<PlaygroundFormValues>()
|
||
const [messageApi, contextHolder] = message.useMessage()
|
||
const [statusLoading, setStatusLoading] = useState(false)
|
||
const [analyzing, setAnalyzing] = useState(false)
|
||
const [providerStatus, setProviderStatus] = useState<AIProviderStatus | null>(null)
|
||
const [analysis, setAnalysis] = useState<SituationalAnalysisResponse | null>(null)
|
||
const [activeTab, setActiveTab] = useState<'request' | 'result'>('request')
|
||
const [helpExpanded, setHelpExpanded] = useState(true)
|
||
|
||
const loadProviderStatus = async (force = false) => {
|
||
if (!force) {
|
||
const cached = sessionStorage.getItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY)
|
||
if (cached) {
|
||
try {
|
||
setProviderStatus(JSON.parse(cached) as AIProviderStatus)
|
||
return
|
||
} catch {
|
||
sessionStorage.removeItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY)
|
||
}
|
||
}
|
||
}
|
||
|
||
setStatusLoading(true)
|
||
try {
|
||
const res = await axios.get<AIProviderStatus>(`${API_BASE_URL}/ai/provider/status`)
|
||
setProviderStatus(res.data)
|
||
sessionStorage.setItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY, JSON.stringify(res.data))
|
||
} catch {
|
||
messageApi.error('AI Provider 状态获取失败')
|
||
} finally {
|
||
setStatusLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void loadProviderStatus()
|
||
}, [])
|
||
|
||
const handleAnalyze = async (values: PlaygroundFormValues) => {
|
||
setAnalyzing(true)
|
||
setActiveTab('result')
|
||
try {
|
||
const res = await axios.post<SituationalAnalysisResponse>(
|
||
`${API_BASE_URL}/ai/situational-awareness/analyze`,
|
||
{
|
||
title: values.title.trim(),
|
||
objective: values.objective.trim(),
|
||
observations: splitLines(values.observations),
|
||
constraints: splitLines(values.constraints),
|
||
context: {
|
||
source: 'playground',
|
||
},
|
||
},
|
||
)
|
||
setAnalysis(res.data)
|
||
messageApi.success('分析已完成')
|
||
} catch {
|
||
messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试')
|
||
setActiveTab('request')
|
||
} finally {
|
||
setAnalyzing(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<AppLayout>
|
||
{contextHolder}
|
||
<div className="page-shell playground-page">
|
||
<div className="page-shell__header playground-page__header">
|
||
<div>
|
||
<Title level={3} style={{ marginBottom: 4 }}>AI Playground</Title>
|
||
<Text type="secondary">
|
||
在前端测试 AI 能力,但实际请求仍统一经由主后端转发到 AI Provider。
|
||
</Text>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="page-shell__body playground-page__body">
|
||
<div className="playground-shell">
|
||
<div className="playground-shell__sidebar">
|
||
<Card
|
||
className="playground-card playground-card--provider"
|
||
title="Provider 状态"
|
||
extra={(
|
||
<Button
|
||
type="text"
|
||
shape="circle"
|
||
icon={<SyncOutlined spin={statusLoading} />}
|
||
onClick={() => void loadProviderStatus(true)}
|
||
aria-label="刷新 Provider 状态"
|
||
className="playground-card__icon-button"
|
||
/>
|
||
)}
|
||
>
|
||
<div className="playground-card__scroll">
|
||
<div className="playground-panel__section">
|
||
<Spin spinning={statusLoading}>
|
||
{providerStatus ? (
|
||
<div className="playground-provider-panel">
|
||
<div className="playground-kv">
|
||
<Text type="secondary">Provider</Text>
|
||
<Text strong>{providerStatus.provider || '-'}</Text>
|
||
</div>
|
||
<div className="playground-kv">
|
||
<Text type="secondary">模型</Text>
|
||
<Text strong>{providerStatus.model || '-'}</Text>
|
||
</div>
|
||
<div className="playground-kv">
|
||
<Text type="secondary">API</Text>
|
||
<Text code>{providerStatus.api || '-'}</Text>
|
||
</div>
|
||
<div className="playground-kv">
|
||
<Text type="secondary">状态</Text>
|
||
<div className="playground-status-tags">
|
||
<Tag color={providerStatus.enabled ? 'green' : 'default'}>
|
||
{providerStatus.enabled ? 'enabled' : 'disabled'}
|
||
</Tag>
|
||
<Tag color={providerStatus.configured ? 'blue' : 'volcano'}>
|
||
{providerStatus.configured ? 'configured' : 'not configured'}
|
||
</Tag>
|
||
</div>
|
||
</div>
|
||
<div className="playground-kv playground-kv--top">
|
||
<Text type="secondary">Base URL</Text>
|
||
<Text code className="playground-code-block">
|
||
{providerStatus.base_url || '-'}
|
||
</Text>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
|
||
)}
|
||
</Spin>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Collapse
|
||
className={`playground-help${helpExpanded ? ' playground-help--expanded' : ' playground-help--collapsed'}`}
|
||
defaultActiveKey={['help']}
|
||
onChange={(keys) => setHelpExpanded(Array.isArray(keys) ? keys.includes('help') : keys === 'help')}
|
||
items={[
|
||
{
|
||
key: 'help',
|
||
label: '测试说明',
|
||
children: (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
className="playground-note"
|
||
message="用于验证 AI Provider 是否可用,以及 backend -> aiprovider 链路是否通畅。"
|
||
description={(
|
||
<span>
|
||
当前链路:
|
||
<Text code>frontend /playground</Text>
|
||
{' -> '}
|
||
<Text code>backend /api/v1/ai/*</Text>
|
||
{' -> '}
|
||
<Text code>aiprovider /v1/*</Text>
|
||
</span>
|
||
)}
|
||
/>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<Card className="playground-card playground-card--workspace">
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={(key) => setActiveTab(key as 'request' | 'result')}
|
||
className="playground-tabs"
|
||
items={[
|
||
{
|
||
key: 'request',
|
||
label: '请求',
|
||
children: (
|
||
<div className="playground-tabpane">
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
initialValues={{
|
||
title: 'BGP 告警态势简报',
|
||
objective: '总结当前告警的主要风险、优先级与建议动作。',
|
||
observations: '出现新的高危告警\n部分观测站最近 24h 事件增多',
|
||
constraints: '结论要简洁\n优先给出操作建议',
|
||
}}
|
||
onFinish={handleAnalyze}
|
||
>
|
||
<Form.Item
|
||
label="标题"
|
||
name="title"
|
||
rules={[{ required: true, message: '请输入标题' }]}
|
||
>
|
||
<Input maxLength={200} placeholder="例如:BGP 告警态势简报" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="目标"
|
||
name="objective"
|
||
rules={[{ required: true, message: '请输入分析目标' }]}
|
||
>
|
||
<Input.TextArea rows={4} maxLength={1000} placeholder="希望模型帮助完成什么分析" />
|
||
</Form.Item>
|
||
|
||
<div className="playground-form__grid playground-form__grid--bottom">
|
||
<Form.Item label="观察项" name="observations">
|
||
<Input.TextArea rows={6} placeholder="每行一条观察,例如:某观测站事件显著增加" />
|
||
</Form.Item>
|
||
|
||
<Form.Item label="约束条件" name="constraints">
|
||
<Input.TextArea rows={6} placeholder="每行一条限制,例如:回答保持简洁" />
|
||
</Form.Item>
|
||
</div>
|
||
|
||
<div className="playground-form__actions">
|
||
<Button type="primary" htmlType="submit" loading={analyzing}>
|
||
开始分析
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
form.resetFields()
|
||
setAnalysis(null)
|
||
}}
|
||
>
|
||
清空
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'result',
|
||
label: '结果',
|
||
children: (
|
||
<div className="playground-tabpane">
|
||
{analyzing ? (
|
||
<div className="playground-result__loading">
|
||
<Spin />
|
||
<Text type="secondary">AI 正在生成结果...</Text>
|
||
</div>
|
||
) : analysis ? (
|
||
<Space direction="vertical" size={16} className="playground-result__stack">
|
||
<div className="playground-result__meta">
|
||
<Tag color="blue">{analysis.provider}</Tag>
|
||
<Tag>{analysis.model}</Tag>
|
||
</div>
|
||
<div className="playground-result__content">
|
||
<pre>{analysis.content}</pre>
|
||
</div>
|
||
{analysis.text_blocks.length ? (
|
||
<div className="playground-result__blocks">
|
||
<Text strong>文本块</Text>
|
||
<div className="playground-result__blocks-scroll">
|
||
{analysis.text_blocks.map((block, index) => (
|
||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||
<pre>{block}</pre>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</Space>
|
||
) : (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="这里会显示 AI 返回的分析结果"
|
||
description="先切到“请求”填写内容并点击“开始分析”。"
|
||
/>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AppLayout>
|
||
)
|
||
}
|
||
|
||
export default Playground
|