fix: expand playground diagnostics and compose fallback

This commit is contained in:
linkong
2026-04-09 16:38:55 +08:00
parent 39f90bd575
commit 306ba7f850
9 changed files with 394 additions and 26 deletions

View File

@@ -1 +1 @@
0.24.2
0.24.3

View File

@@ -7,6 +7,25 @@ This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1`
## 0.24.3
Released: 2026-04-09
### Highlights
- Extended `AI Playground` from a minimal prompt form into a more repeatable diagnostics workspace, and hardened `planet.sh` so AI Provider restarts can rebuild changed images and expose clearer Compose fallback behavior.
### Improved
- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) by adding preset scenarios, response metadata, `thinking` block visibility, raw JSON inspection, and copy actions so the page works more like a proper AI diagnostics console.
- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by styling Playground presets, result metadata, raw response sections, and responsive action groups without breaking the single-screen workspace layout.
- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so AI Provider restarts detect code/config changes, rebuild the image when needed, and surface which Compose path is being used during image and container operations.
### Fixed
- Fixed the AI Provider restart path in [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so code changes inside `aiprovider/` no longer stay hidden behind an old container image after `restart -a`.
- Fixed Compose error reporting in [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by making the script explicitly show `docker compose v2` first, then `docker-compose v1`, and only fail after both execution paths are exhausted.
## 0.24.2
Released: 2026-04-09

View File

@@ -16,7 +16,7 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.24.2`
- `dev` 当前开发分支历史推导到:`0.24.3`
## Timeline
@@ -75,6 +75,7 @@
| `0.24.0` | feature | `dev` | `pending` | add AI Playground entry, provider diagnostics, and frontend layout guidance |
| `0.24.1` | bugfix | `dev` | `pending` | refactor Earth HUD into class-first CSS layers, unify Bun-only frontend tooling guidance, and auto-bootstrap Bun/uv in `planet.sh` |
| `0.24.2` | bugfix | `dev` | `pending` | restore public Earth entry, refresh Playground provider diagnostics correctly, fit help-card content, and split frontend bundles by route/vendor |
| `0.24.3` | bugfix | `dev` | `pending` | expand Playground diagnostics presets and result inspection, and make `planet.sh` rebuild changed AI Provider images with explicit Compose fallback reporting |
## Maintenance Commits Not Counted as Version Bumps

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.24.2",
"version": "0.24.3",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -380,6 +380,23 @@ body {
padding-bottom: 4px;
}
.playground-preset-strip {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
.playground-preset-strip__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.playground-form__actions {
display: flex;
gap: 12px;
@@ -555,10 +572,23 @@ body {
.playground-result__meta {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.playground-result__meta-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.playground-result__descriptions .ant-descriptions-view {
border-radius: 14px;
overflow: hidden;
}
.playground-result__content,
.playground-result__blocks .ant-card {
border-radius: 14px;
@@ -591,6 +621,13 @@ body {
gap: 12px;
}
.playground-result__blocks-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.playground-result__blocks-scroll {
max-height: 180px;
overflow: auto;
@@ -619,6 +656,12 @@ body {
min-width: 0;
max-width: none;
}
.playground-result__meta,
.playground-result__blocks-head {
align-items: stretch;
flex-direction: column;
}
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { SyncOutlined } from '@ant-design/icons'
import { CopyOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Collapse,
Descriptions,
Form,
Input,
Space,
@@ -53,6 +54,51 @@ interface PlaygroundFormValues {
constraints?: string
}
interface PlaygroundPreset {
key: string
label: string
values: PlaygroundFormValues
}
interface AnalysisRunMeta {
requestId: string | null
durationMs: number | null
completedAt: string | null
}
const PLAYGROUND_PRESETS: PlaygroundPreset[] = [
{
key: 'bgp-brief',
label: 'BGP 简报',
values: {
title: 'BGP 告警态势简报',
objective: '总结当前告警的主要风险、优先级与建议动作。',
observations: '出现新的高危告警\n部分观测站最近 24h 事件增多',
constraints: '结论要简洁\n优先给出操作建议',
},
},
{
key: 'datasource-health',
label: '数据源健康',
values: {
title: '采集器健康检查说明',
objective: '判断当前采集器失败是否属于上游接口失效、限流、结构变更或临时波动。',
observations: '最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定',
constraints: '区分事实与推断\n先给排障优先级',
},
},
{
key: 'link-smoke',
label: '链路探测',
values: {
title: 'AI 链路探测',
objective: '验证 backend -> aiprovider -> model provider 调用链路是否正常。',
observations: '当前从 Playground 发起测试\n希望确认 provider 配置和返回结构正常',
constraints: '输出简洁\n包含一段明确结论',
},
},
]
function splitLines(value?: string) {
return (value || '')
.split('\n')
@@ -67,6 +113,12 @@ function Playground() {
const [analyzing, setAnalyzing] = useState(false)
const [providerStatus, setProviderStatus] = useState<AIProviderStatus | null>(null)
const [analysis, setAnalysis] = useState<SituationalAnalysisResponse | null>(null)
const [analysisMeta, setAnalysisMeta] = useState<AnalysisRunMeta>({
requestId: null,
durationMs: null,
completedAt: null,
})
const [providerStatusUpdatedAt, setProviderStatusUpdatedAt] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'request' | 'result'>('request')
const [helpExpanded, setHelpExpanded] = useState(true)
@@ -87,6 +139,7 @@ function Playground() {
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))
setProviderStatusUpdatedAt(new Date().toLocaleString())
} catch {
messageApi.error('AI Provider 状态获取失败')
} finally {
@@ -99,6 +152,7 @@ function Playground() {
}, [])
const handleAnalyze = async (values: PlaygroundFormValues) => {
const startedAt = performance.now()
setAnalyzing(true)
setActiveTab('result')
try {
@@ -115,6 +169,11 @@ function Playground() {
},
)
setAnalysis(res.data)
setAnalysisMeta({
requestId: typeof res.headers['x-request-id'] === 'string' ? res.headers['x-request-id'] : null,
durationMs: Math.round(performance.now() - startedAt),
completedAt: new Date().toLocaleString(),
})
messageApi.success('分析已完成')
} catch {
messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试')
@@ -124,6 +183,31 @@ function Playground() {
}
}
const handleApplyPreset = (preset: PlaygroundPreset) => {
form.setFieldsValue(preset.values)
setActiveTab('request')
}
const handleCopyResult = async () => {
if (!analysis?.content) return
try {
await navigator.clipboard.writeText(analysis.content)
messageApi.success('结果已复制')
} catch {
messageApi.error('复制失败,请稍后再试')
}
}
const handleCopyRawResponse = async () => {
if (!analysis) return
try {
await navigator.clipboard.writeText(JSON.stringify(analysis.raw_response, null, 2))
messageApi.success('原始响应已复制')
} catch {
messageApi.error('复制失败,请稍后再试')
}
}
return (
<AppLayout>
{contextHolder}
@@ -188,6 +272,10 @@ function Playground() {
{providerStatus.base_url || '-'}
</Text>
</div>
<div className="playground-kv">
<Text type="secondary"></Text>
<Text>{providerStatusUpdatedAt || '-'}</Text>
</div>
</div>
) : (
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
@@ -223,6 +311,13 @@ function Playground() {
</span>
)}
/>
<Alert
type="success"
showIcon
className="playground-note"
message="推荐使用左侧预设场景快速联调。"
description="先看 Provider 状态,再选择一个预设模版发起请求,最后在结果区检查 request id、thinking blocks 和 raw response。"
/>
</div>
),
},
@@ -241,15 +336,20 @@ function Playground() {
label: '请求',
children: (
<div className="playground-tabpane">
<div className="playground-preset-strip">
<Text type="secondary"></Text>
<div className="playground-preset-strip__actions">
{PLAYGROUND_PRESETS.map((preset) => (
<Button key={preset.key} onClick={() => handleApplyPreset(preset)}>
{preset.label}
</Button>
))}
</div>
</div>
<Form
form={form}
layout="vertical"
initialValues={{
title: 'BGP 告警态势简报',
objective: '总结当前告警的主要风险、优先级与建议动作。',
observations: '出现新的高危告警\n部分观测站最近 24h 事件增多',
constraints: '结论要简洁\n优先给出操作建议',
}}
initialValues={PLAYGROUND_PRESETS[0].values}
onFinish={handleAnalyze}
>
<Form.Item
@@ -284,11 +384,16 @@ function Playground() {
</Button>
<Button
onClick={() => {
form.resetFields()
form.setFieldsValue(PLAYGROUND_PRESETS[0].values)
setAnalysis(null)
setAnalysisMeta({
requestId: null,
durationMs: null,
completedAt: null,
})
}}
>
</Button>
</div>
</Form>
@@ -308,9 +413,30 @@ function Playground() {
) : 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 className="playground-result__meta-tags">
<Tag color="blue">{analysis.provider}</Tag>
<Tag>{analysis.model}</Tag>
</div>
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => void handleCopyResult()}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => setActiveTab('request')}>
</Button>
</Space>
</div>
<Descriptions
size="small"
bordered
className="playground-result__descriptions"
column={1}
items={[
{ key: 'requestId', label: 'Request ID', children: analysisMeta.requestId || '-' },
{ key: 'duration', label: '耗时', children: analysisMeta.durationMs ? `${analysisMeta.durationMs} ms` : '-' },
{ key: 'completedAt', label: '完成时间', children: analysisMeta.completedAt || '-' },
]}
/>
<div className="playground-result__content">
<pre>{analysis.content}</pre>
</div>
@@ -326,6 +452,31 @@ function Playground() {
</div>
</div>
) : null}
{analysis.thinking_blocks.length ? (
<div className="playground-result__blocks">
<Text strong>Thinking Blocks</Text>
<div className="playground-result__blocks-scroll">
{analysis.thinking_blocks.map((block, index) => (
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
<pre>{block}</pre>
</Card>
))}
</div>
</div>
) : null}
<div className="playground-result__blocks">
<div className="playground-result__blocks-head">
<Text strong>Raw Response</Text>
<Button size="small" icon={<CopyOutlined />} onClick={() => void handleCopyRawResponse()}>
JSON
</Button>
</div>
<div className="playground-result__blocks-scroll">
<Card size="small">
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
</Card>
</div>
</div>
</Space>
) : (
<Alert

174
planet.sh
View File

@@ -59,6 +59,10 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
AI_PROVIDER_RECREATE_REQUIRED=0
prepend_path_once() {
local path_entry="$1"
@@ -79,12 +83,55 @@ ensure_local_runtime_bins_on_path() {
ensure_local_runtime_bins_on_path
# Shell / Docker helpers
compose_v2_available() {
docker compose version >/dev/null 2>&1
}
compose_v1_available() {
command -v docker-compose >/dev/null 2>&1
}
report_missing_compose() {
clear_wait_spinner
log_error "未检测到可用的 Docker Compose"
log_note "优先需要 Docker Compose v2 (`docker compose`)"
log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行"
log_note "推荐安装 Docker Compose v2 插件:"
log_note ' mkdir -p ~/.docker/cli-plugins'
log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose'
log_note ' chmod +x ~/.docker/cli-plugins/docker-compose'
log_note ' docker compose version'
return 1
}
compose_up() {
if docker compose version >/dev/null 2>&1; then
docker compose "$@"
else
docker-compose "$@"
local args=("$@")
local arg_text="$*"
if compose_v2_available; then
set_wait_detail "正在用 docker compose v2 执行: ${arg_text}"
if docker compose "${args[@]}"; then
return 0
fi
log_warn "docker compose v2 执行失败,回退到 docker-compose v1"
fi
if compose_v1_available; then
set_wait_detail "正在用 docker-compose v1 执行: ${arg_text}"
if docker-compose "${args[@]}"; then
return 0
fi
clear_wait_spinner
log_error "docker compose v2 与 docker-compose v1 均执行失败"
log_note "最后尝试的命令: docker-compose ${arg_text}"
return 1
fi
report_missing_compose
}
compose_supports_build() {
compose_up build --help >/dev/null 2>&1
}
# Terminal rendering helpers
@@ -338,6 +385,102 @@ run_with_retry() {
return 1
}
compute_ai_provider_build_fingerprint() {
(
cd "$SCRIPT_DIR" || exit 1
tar -cf - \
aiprovider \
pyproject.toml \
uv.lock \
docker-compose.yml \
docker-compose.simple.yml 2>/dev/null
) | sha256sum | awk '{print $1}'
}
read_ai_provider_build_stamp() {
[ -f "$AI_PROVIDER_BUILD_STAMP_FILE" ] || return 1
tr -d '[:space:]' < "$AI_PROVIDER_BUILD_STAMP_FILE"
}
write_ai_provider_build_stamp() {
local fingerprint="$1"
printf "%s\n" "$fingerprint" > "$AI_PROVIDER_BUILD_STAMP_FILE"
}
ai_provider_image_exists() {
docker image inspect "$AI_PROVIDER_IMAGE_NAME" >/dev/null 2>&1
}
list_ai_provider_containers() {
docker ps -aq --filter "name=planet_aiprovider" 2>/dev/null || true
}
remove_ai_provider_containers() {
local container_ids
container_ids="$(list_ai_provider_containers)"
[ -n "$container_ids" ] || return 0
echo "$container_ids" | xargs -r docker rm -f >/dev/null 2>&1 || true
}
run_ai_provider_container_manually() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
local env_file_args=()
if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then
env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env")
fi
docker run -d \
--name "$AI_PROVIDER_CONTAINER_NAME" \
"${env_file_args[@]}" \
-p "${ai_provider_port}:8010" \
"$AI_PROVIDER_IMAGE_NAME" >/dev/null
}
recreate_ai_provider_container() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
remove_ai_provider_containers
if compose_up up -d aiprovider >/dev/null 2>&1; then
return 0
fi
run_ai_provider_container_manually "$ai_provider_port"
}
ensure_ai_provider_image_current() {
local current_fingerprint=""
local previous_fingerprint=""
current_fingerprint="$(compute_ai_provider_build_fingerprint)"
previous_fingerprint="$(read_ai_provider_build_stamp || true)"
if ai_provider_image_exists && [ -n "$previous_fingerprint" ] && [ "$current_fingerprint" = "$previous_fingerprint" ]; then
AI_PROVIDER_RECREATE_REQUIRED=0
return 0
fi
set_wait_detail "检测到 AI Provider 代码或配置变化,重建镜像"
if ! compose_supports_build; then
log_error "当前 docker compose 不支持 build请检查 Docker / Compose 环境"
exit 1
fi
if ! run_with_retry \
"$AI_PROVIDER_START_MAX_RETRIES" \
"$AI_PROVIDER_RETRY_INTERVAL" \
"AI Provider 镜像构建失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES}" \
"构建 AI Provider 镜像" \
compose_up build aiprovider; then
exit 1
fi
write_ai_provider_build_stamp "$current_fingerprint"
AI_PROVIDER_RECREATE_REQUIRED=1
}
install_uv_if_needed() {
if command -v uv >/dev/null 2>&1; then
return 0
@@ -753,9 +896,19 @@ start_ai_provider_service() {
local retry=1
set_wait_detail "启动 AI Provider"
ensure_ai_provider_image_current
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do
if docker start planet_aiprovider >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then
if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
if [ "$AI_PROVIDER_RECREATE_REQUIRED" -eq 1 ]; then
if recreate_ai_provider_container "$ai_provider_port"; then
AI_PROVIDER_RECREATE_REQUIRED=0
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
return 0
fi
fi
elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
return 0
fi
@@ -763,17 +916,18 @@ start_ai_provider_service() {
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES}"
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true
exit 1
fi
docker restart planet_aiprovider >/dev/null 2>&1 || true
remove_ai_provider_containers
AI_PROVIDER_RECREATE_REQUIRED=1
animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL"
retry=$((retry + 1))
done
log_error "AI Provider 启动失败"
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true
exit 1
}
@@ -1179,7 +1333,7 @@ stop_backend_service() {
}
stop_ai_provider_service() {
stop_container_if_running "planet_aiprovider" "AI Provider"
stop_container_if_running "$AI_PROVIDER_CONTAINER_NAME" "AI Provider"
}
stop_frontend_service() {

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.24.2"
version = "0.24.3"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.24.2"
version = "0.24.3"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },