Add AI playground and frontend layout guidelines

This commit is contained in:
rayd1o
2026-04-09 00:35:38 +08:00
parent 5639546990
commit ef65acd49c
14 changed files with 1340 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.23.4",
"version": "0.24.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -8,6 +8,7 @@ import DataList from './pages/DataList/DataList'
import Earth from './pages/Earth/Earth'
import Settings from './pages/Settings/Settings'
import BGP from './pages/BGP/BGP'
import Playground from './pages/Playground/Playground'
function App() {
const { token } = useAuthStore()
@@ -26,6 +27,7 @@ function App() {
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />
<Route path="/bgp" element={<BGP />} />
<Route path="/playground" element={<Playground />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>

View File

@@ -7,6 +7,7 @@ import {
SettingOutlined,
BarChartOutlined,
DeploymentUnitOutlined,
RobotOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
} from '@ant-design/icons'
@@ -34,6 +35,7 @@ function AppLayout({ children }: AppLayoutProps) {
{ key: '/datasources', icon: <DatabaseOutlined />, label: '数据源' },
{ key: '/data', icon: <BarChartOutlined />, label: '采集数据' },
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP观测' },
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
]

View File

@@ -183,6 +183,454 @@ body {
min-height: 0;
}
.playground-page__grid {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
align-items: stretch;
}
.playground-page__body {
overflow: hidden;
}
.playground-shell {
flex: 1 1 auto;
min-height: 0;
display: flex;
gap: 12px;
}
.playground-shell__sidebar {
flex: 0 0 340px;
min-width: 300px;
max-width: 360px;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 12px;
}
.playground-card {
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
border-radius: 16px;
}
.playground-card .ant-card-head {
min-height: 56px;
padding: 0 20px;
flex: 0 0 auto;
}
.playground-card .ant-card-head-title {
padding: 14px 0;
}
.playground-card__icon-button {
color: #64748b;
}
.playground-card__icon-button:hover {
color: #1677ff !important;
background: rgba(22, 119, 255, 0.08) !important;
}
.playground-card .ant-card-body {
flex: 1 1 auto;
min-height: 0;
padding: 16px 20px 14px;
}
.playground-card__scroll {
height: 100%;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-card__scroll::-webkit-scrollbar,
.playground-result__content::-webkit-scrollbar,
.playground-result__blocks-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.playground-card__scroll::-webkit-scrollbar-thumb,
.playground-result__content::-webkit-scrollbar-thumb,
.playground-result__blocks-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.playground-card__scroll::-webkit-scrollbar-thumb:hover,
.playground-result__content::-webkit-scrollbar-thumb:hover,
.playground-result__blocks-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
background-clip: padding-box;
}
.playground-card__scroll::-webkit-scrollbar-track,
.playground-result__content::-webkit-scrollbar-track,
.playground-result__blocks-scroll::-webkit-scrollbar-track {
background: transparent;
}
.playground-card--provider {
flex: 1 1 auto;
min-height: 180px;
}
.playground-card--provider .ant-card-body {
overflow: hidden;
}
.playground-card--help {
flex: 1 1 auto;
}
.playground-card--workspace {
flex: 1 1 auto;
}
.playground-card--workspace .ant-card-body,
.playground-card--result .ant-card-body {
overflow: hidden;
}
.playground-tabs,
.playground-tabs .ant-tabs-content-holder,
.playground-tabs .ant-tabs-content,
.playground-tabs .ant-tabs-tabpane {
height: 100%;
min-height: 0;
}
.playground-tabs {
display: flex;
flex-direction: column;
}
.playground-tabs .ant-tabs-nav {
flex: 0 0 auto;
margin-bottom: 12px;
}
.playground-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
overflow: hidden;
}
.playground-tabpane {
height: 100%;
min-height: 0;
overflow: auto;
padding-right: 2px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-tabpane::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.playground-tabpane::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.playground-tabpane::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
background-clip: padding-box;
}
.playground-tabpane::-webkit-scrollbar-track {
background: transparent;
}
.playground-form__grid {
display: grid;
gap: 12px;
}
.playground-form__grid--bottom {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.playground-form__grid .ant-form-item {
min-width: 0;
}
.playground-tabpane .ant-form {
padding-bottom: 4px;
}
.playground-form__actions {
display: flex;
gap: 12px;
}
.playground-card--request .ant-form-item {
margin-bottom: 8px;
}
.playground-card--request .ant-form-item-label {
padding-bottom: 4px;
}
.playground-card--request .ant-form-item-label > label {
font-size: 13px;
}
.playground-card--result {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.playground-card--result .ant-card-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.playground-provider-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.playground-kv {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: start;
column-gap: 14px;
row-gap: 4px;
}
.playground-kv > .ant-typography:first-child {
flex: 0 0 auto;
white-space: nowrap;
}
.playground-kv > .ant-typography:last-child,
.playground-kv .playground-status-tags {
min-width: 0;
justify-self: end;
text-align: right;
}
.playground-card--provider .ant-card-body {
padding-top: 18px;
padding-bottom: 16px;
}
.playground-card--provider .playground-kv + .playground-kv {
margin-top: 2px;
}
.playground-kv--top {
align-items: start;
}
.playground-status-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.playground-code-block {
white-space: pre-wrap;
word-break: break-all;
}
.playground-note {
border-radius: 12px;
}
.playground-help {
flex: 0 0 auto;
min-height: 0;
border-radius: 16px;
overflow: hidden;
}
.playground-help--collapsed {
flex: 0 0 auto;
}
.playground-help--expanded {
flex: 1 0 auto;
}
.playground-help.ant-collapse {
display: flex;
flex-direction: column;
}
.playground-help > .ant-collapse-item {
display: flex;
flex-direction: column;
min-height: 0;
}
.playground-help .ant-collapse-header {
padding: 14px 20px !important;
align-items: center !important;
flex: 0 0 auto;
}
.playground-help .ant-collapse-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.playground-help .ant-collapse-content-box {
height: 100%;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
padding: 12px 16px 16px !important;
}
.playground-help .ant-collapse-content-box::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.playground-help .ant-collapse-content-box::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.playground-help .ant-collapse-content-box::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
background-clip: padding-box;
}
.playground-help .ant-collapse-content-box::-webkit-scrollbar-track {
background: transparent;
}
.playground-note.ant-alert {
padding: 10px 12px;
}
.playground-note .ant-alert-icon {
margin-inline-end: 10px;
font-size: 16px;
}
.playground-note .ant-alert-message {
font-size: 13px;
font-weight: 600;
line-height: 1.5;
}
.playground-note .ant-alert-description {
font-size: 12px;
margin-top: 6px;
line-height: 1.5;
}
.playground-result__loading {
flex: 1 1 auto;
min-height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.playground-result__stack {
flex: 1 1 auto;
min-height: 0;
}
.playground-result__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.playground-result__content,
.playground-result__blocks .ant-card {
border-radius: 14px;
background: #0f172a;
color: #e2e8f0;
}
.playground-result__content {
flex: 1 1 auto;
min-height: 0;
padding: 16px;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-result__content pre,
.playground-result__blocks pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
line-height: 1.6;
}
.playground-result__blocks {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.playground-result__blocks-scroll {
max-height: 180px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
@media (max-width: 1200px) {
.playground-page__header {
align-items: flex-start;
}
.playground-form__grid--bottom {
grid-template-columns: 1fr;
}
.playground-shell {
flex-direction: column;
}
.playground-shell__sidebar {
flex: 0 0 auto;
min-width: 0;
max-width: none;
}
}
.data-source-tabs-shell,
.data-source-tabs,

View File

@@ -0,0 +1,350 @@
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