diff --git a/README.md b/README.md index 1df787fb..3fe1507d 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,22 @@ AI_PROVIDER_SERVICE_TOKEN=change_me - [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) - [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) +- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) +- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) + +## 前端页面布局规范 + +管理后台页面默认遵循“单屏工作区”原则: + +- 页头、摘要区、主工作区应在一屏内形成稳定结构 +- 主表格 / 主图表 / 主分析区应占据页面主要可视空间 +- 模块内容超出时优先在卡片、表格、标签页内部滚动 +- 不依赖整页纵向撑开来容纳主要工作区 + +当前推荐参考实现: + +- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) +- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) ## License diff --git a/VERSION b/VERSION index 40a6dfed..2094a100 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.4 +0.24.0 diff --git a/docker-compose.yml b/docker-compose.yml index 89ab58ce..329ecff7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: . dockerfile: aiprovider/Dockerfile + env_file: + - ./aiprovider/.env container_name: planet_aiprovider ports: - "8010:8010" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 74842b7c..ea2a10cb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,35 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.24.0 + +Released: 2026-04-09 + +### Highlights + +- Added a dedicated `AI Playground` admin entry so operators can validate provider connectivity and run controlled situational-analysis prompts from the main frontend without introducing a separate UI service. +- Established a first explicit frontend layout rulebook centered on single-screen workspaces, internal module scrolling, and BGP-style page composition for future admin pages. + +### Added + +- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance. +- Added [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling. +- Added [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion. + +### Improved + +- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) and [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) by wiring `AI Playground` into the main admin navigation and route tree instead of pointing operators to a nonexistent `aiprovider` chat page. +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by replacing the incorrect `localhost:8010/chat` closeout link with the frontend `AI Playground` entry. +- Improved [docker-compose.yml](/home/ray/dev/linkong/planet/docker-compose.yml) by attaching `./aiprovider/.env` to the `aiprovider` service so provider identity, model, and credentials actually reach the running container. +- Improved [README.md](/home/ray/dev/linkong/planet/README.md) by linking the new frontend layout guidance and AI Playground development plan. +- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by refining the Playground workspace into a notebook-friendly left-sidebar plus right-tabbed layout, reusing thin scrollbars, and making provider/help/result regions degrade more gracefully under constrained height. + +### Fixed + +- Fixed the local AI status flow so provider configuration no longer appeared permanently `disabled / not configured` merely because `aiprovider/.env` was not mounted into the container. +- Fixed repeated `Provider 状态` refetching when switching away from and back to `/playground` by caching the last known provider status within the browser session until the operator explicitly refreshes it. +- Fixed several Playground layout regressions where auxiliary panels could push the result area out of view or clip provider details without exposing internal scrolling. + ## 0.23.4 Released: 2026-04-08 diff --git a/docs/ai-playground-development-plan.md b/docs/ai-playground-development-plan.md new file mode 100644 index 00000000..6568da12 --- /dev/null +++ b/docs/ai-playground-development-plan.md @@ -0,0 +1,318 @@ +# AI Playground Development Plan + +## 目标 + +这份计划用于统一 `aiprovider`、`backend AI facade`、`Playground` 页面,以及后续 `BGP / 告警 / 数据源健康` 等 AI 入口的演进方向。 + +当前原则: + +- `aiprovider` 继续作为独立模型网关 +- `backend` 继续作为稳定业务入口 +- `frontend` 负责测试台和业务 UI +- 先做“可控、可验证、可解释”的 AI 能力,再逐步引入 agent/tool calling + +## 当前已完成 + +### 1. AI 网关基础层 + +已完成: + +- 独立 `aiprovider` 服务 +- `backend -> aiprovider -> model provider` 调用链 +- `provider/status` 与 `situational-awareness/analyze` 稳定接口 +- `X-Request-ID` 透传 +- 轻量超时与重试 +- MiniMax / Anthropic-compatible / OpenAI-compatible / Ollama 适配 + +相关文件: + +- [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py) +- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py) +- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py) +- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py) +- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) + +### 2. 本地运行与配置打通 + +已完成: + +- `planet.sh` 启动链路纳入 `aiprovider` +- `planet.sh` 启动完成后输出 Playground 入口 +- `docker-compose.yml` 为 `aiprovider` 加入 `env_file` +- `backend/.env` 与 `aiprovider/.env` 两侧 service token 对齐 +- `Playground` 状态缓存,避免页面切换时每次都重新请求 provider 状态 + +相关文件: + +- [planet.sh](/home/ray/dev/linkong/planet/planet.sh) +- [docker-compose.yml](/home/ray/dev/linkong/planet/docker-compose.yml) +- [backend/.env.example](/home/ray/dev/linkong/planet/backend/.env.example) +- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) + +### 3. Playground UI 基础版 + +已完成: + +- 新增前端路由 `/playground` +- 左侧 `Provider 状态 + 测试说明` +- 右侧 `请求 / 结果` Tabs +- `Provider 状态` 支持手动刷新 +- `测试说明` 支持折叠 +- 内部区域采用细滚动条 +- 页面布局开始遵循“单屏工作区 + 模块内部滚动”规范 + +相关文件: + +- [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) +- [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) +- [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) +- [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) + +### 4. 前端布局规范沉淀 + +已完成: + +- 把“一屏工作区、主模块优先、模块内部滚动”的规范文档化 +- 明确 `BGP` 页面为当前参考实现 + +相关文件: + +- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) +- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) + +## 当前限制 + +### 1. Playground 还是 prompt playground,不是 agent playground + +当前 `Playground` 的 `观察项 / 目标 / 约束条件` 都是人工输入。 + +模型现在拿到的是: + +- 你手工输入的结构化字段 +- 后端传递的少量静态上下文 + +模型现在拿不到: + +- 实时 BGP 事件 +- 真实告警列表 +- 数据源健康状态 +- 自动检索结果 +- tool calling / skills / 自主取数 + +### 2. `situational-awareness/analyze` 还是通用提示词接口 + +当前更适合: + +- 测试链路 +- 测试模型输出风格 +- 验证不同 provider 是否正常返回 + +当前还不适合: + +- 直接当真实态势系统主入口 +- 让用户手工维护长期分析模板 +- 代替专用业务研判接口 + +### 3. 还没有可验证的真实业务输入注入 + +目前最缺的是: + +- 从业务系统自动整理“事实输入” +- 再把这些事实喂给 AI + +而不是继续让用户在 Playground 手工输入真实事件摘要。 + +## 短期计划 + +### Phase A: Playground 收敛为稳定测试台 + +目标: + +- 保持 Playground 简洁可用 +- 不再继续堆“高级参数” + +工作项: + +- 继续微调左侧 `Provider 状态` 与 `测试说明` 的空间策略 +- 保持 `请求 / 结果` 为单一主工作区 +- 不引入盲填式高级字段 +- 统一滚动条、卡片、溢出行为 + +完成标准: + +- 笔记本视口下依然可用 +- 各模块标题可见 +- 主要阅读区始终是右侧 Tabs + +### Phase B: BGP AI 简报 + +目标: + +- 不再依赖手工填写“观察项” +- 让系统自动把真实 BGP 数据注入 AI + +建议实现: + +- 新增专用后端接口,例如: + - `POST /api/v1/ai/bgp/brief` +- 后端自动读取: + - incidents summary + - anomalies + - recent events + - collector coverage summary +- 后端将结构化事实注入 `context / observations` +- 前端在 BGP 页面增加“生成 AI 简报” + +完成标准: + +- 用户不需要手工录入 BGP 观察项 +- AI 输出能明确区分“事实”和“研判” + +### Phase C: 告警 / 数据源健康 AI 简报 + +目标: + +- 复用同样模式,扩展到其他模块 + +建议入口: + +- `Alerts` 页面:异常与告警摘要 +- `DataSources` 页面:采集失败与健康状态总结 + +原则: + +- 每个业务页优先做“专用 AI 简报” +- 不优先做“万能大聊天框” + +## 中期计划 + +### 1. Assessment Layer + +目标: + +- 不只返回自由文本 +- 返回结构化的 assessment + +建议输出字段: + +- summary +- key_risks +- evidence +- recommendations +- confidence +- missing_data + +这样后续才能: + +- 持久化 +- 回看 +- 对比不同时间的 AI 结论 +- 在 Earth / Dashboard / BGP 页面稳定展示 + +### 2. Evidence-first Runtime + +目标: + +- 所有 AI 分析先取真实数据,再调模型 + +原则: + +- 先 evidence +- 再 prompt +- 最后才是自由生成 + +优先要做的不是更强聊天,而是: + +- 更稳定的数据注入 +- 更一致的事实模板 +- 更清晰的结果结构 + +### 3. 按页面提供专用入口 + +目标: + +- 让 AI 成为业务视图的一部分,而不是孤立 playground + +优先顺序建议: + +1. `BGP` AI 简报 +2. `Alerts` AI 简报 +3. `DataSources` 健康研判 +4. `Dashboard` 总览总结 + +## 长期计划 + +### 1. Tool Calling / Agent Runtime + +只有在以下基础稳定后再推进: + +- 数据源健康信号稳定 +- BGP / Alerts / Datasource evidence 注入稳定 +- assessment 结构稳定 + +长期可做能力: + +- AI 调用受控工具查询业务数据 +- AI 调用检索/web search 做外部验证 +- AI 生成建议而不是直接修改系统 +- 审核后触发受控动作 + +### 2. 受控动作与闭环 + +潜在方向: + +- 根据健康异常生成修复建议 +- 根据态势变化生成处理建议 +- 进入 review queue +- 审批后执行 +- 验证结果并形成闭环 + +### 3. 多模块统一 AI 体验 + +长期目标不是一个孤立 Playground,而是: + +- 每个业务页都有自己的 AI 入口 +- 共享统一的 backend AI facade +- 共享统一的 assessment 结构 +- 共享统一的 evidence 注入与审计链路 + +## 设计决策总结 + +### 为什么保留 `aiprovider` + +因为它已经很好地承担了: + +- provider 适配 +- 协议兼容 +- service token 边界 +- 独立重启与部署 + +因此短期内不建议把它并回 `backend`。 + +### 为什么 Playground 不做成万能聊天页 + +因为当前更需要的是: + +- 稳定测试链路 +- 可验证业务输入 +- 专用分析入口 + +而不是一个泛化但没有真实数据支撑的聊天框。 + +### 为什么优先做专用 AI 简报 + +因为: + +- 数据可以自动注入 +- 用户心智更清晰 +- 输出更容易结构化 +- 更容易校验事实与研判是否一致 + +## 下一步建议 + +按优先级建议接下来这样做: + +1. 稳住 `Playground` 当前布局,不再大幅重做 +2. 在 `BGP` 页面新增专用 “AI 简报” 入口 +3. 后端新增 `BGP brief` 专用接口,自动注入真实数据 +4. 把 AI 输出逐步从自由文本升级为结构化 assessment diff --git a/docs/frontend-layout-guidelines.md b/docs/frontend-layout-guidelines.md new file mode 100644 index 00000000..bf35c337 --- /dev/null +++ b/docs/frontend-layout-guidelines.md @@ -0,0 +1,168 @@ +# Frontend Layout Guidelines + +本项目后台页面默认遵循“单屏工作区”布局规范。目标不是让页面永远不溢出,而是确保在常见桌面视口下: + +- 页面主结构能在一屏内看清 +- 用户能同时看到页头、摘要区和主工作区 +- 超出的内容在模块内部滚动,而不是把整页纵向撑爆 + +当前推荐参考实现: + +- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) +- [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) + +## 核心原则 + +### 1. 页面优先保证一屏工作区 + +管理页默认采用: + +- 页头:标题、说明、主要操作 +- 主工作区:统计卡、表格、图表、列表、标签页 + +推荐结构: + +```tsx + +
+
...
+
...
+
+
+``` + +页面总高度应被限制在 `AppLayout` 内容区内,而不是继续让整个页面自然向下增长。 + +### 2. 滚动优先发生在模块内部 + +如果表格、日志、长列表、图表明细超出空间: + +- 让卡片内部滚动 +- 让表格内部滚动 +- 让标签页内容区内部滚动 + +不要默认依赖整个页面滚动去“解决”空间问题。 + +### 3. 主工作区必须拿到主要空间 + +页面里最重要的模块必须是视觉和空间上的主角。通常应保证: + +- 页头始终可见 +- 摘要区高度被控制 +- 主表格 / 主图表 / 主分析区占据 50% 以上可视高度 + +如果一个页面有多个大模块,优先顺序是: + +1. 先压缩说明区和摘要区 +2. 再把次级模块收进标签页或切换视图 +3. 最后才考虑继续增加整页滚动 + +### 4. 小屏幕和高缩放必须进入紧凑模式 + +在窗口高度较低、宽度较窄、或系统缩放较高时,应主动切换紧凑布局,例如: + +- 缩小卡片 padding +- 缩小表头和单元格间距 +- 将摘要区改为更紧凑的单行/横向滚动布局 +- 将次级模块移入标签页、抽屉、折叠区 + +紧凑模式的目标是保持可用,不是单纯把文字和控件一股脑缩小。 + +### 5. overflow 责任必须明确 + +页面中的大块内容必须明确: + +- 谁负责占满剩余高度 +- 谁负责裁剪 +- 谁负责滚动 + +常见要求: + +- 父容器链路需要 `min-height: 0` +- 工作区容器通常需要 `display: flex` +- 真正的滚动节点要显式 `overflow: auto` + +## 推荐实现模式 + +### 页面骨架 + +优先复用项目里已有的通用结构: + +- `.dashboard-content-inner` +- `.page-shell` +- `.page-shell__header` +- `.page-shell__body` +- `.table-scroll-region` + +不要每个页面都重新发明一套完全不同的高度和滚动语义。 + +### 表格工作区 + +推荐模式: + +```tsx + +
+ + + +``` + +要求: + +- 表格尽量在卡片内部滚动 +- `scroll.y` 应来自实际可用高度估算,而不是完全静态的魔法数字 +- 父容器链路要保证 header、body、content 的 overflow 都在表格内部闭合 + +### 多模块页面 + +如果一个页面同时有: + +- 摘要卡 +- 表格 +- 异常明细 +- 最近事件 + +不建议简单纵向堆叠全部模块。优先使用: + +- 顶部摘要 + 底部单一主工作区 +- 标签页切换多个次级数据视图 +- 左右分栏,并保证每栏内部独立滚动 + +## 不推荐的做法 + +以下模式默认视为不符合本项目页面规范: + +- 依赖整页纵向滚动来显示主要工作区 +- 一个页面纵向堆 3 到 4 个大卡片,每个都想完整展示 +- 表格没有内部滚动,导致缩放后只能看到 1 到 2 行数据 +- 父容器缺少 `min-height: 0`,导致内部滚动失效 +- 只做视觉缩小,不处理真正的空间分配 + +## 页面验收检查清单 + +提交前至少检查: + +- 页头、摘要区、主工作区能否同时出现 +- 主工作区是否拿到了页面中最多的高度 +- 表格或明细溢出时,滚动条是否出现在模块内部 +- 浏览器缩放到 `125%` / `150%` 时是否仍可用 +- 低高度窗口下是否还保有合理的可见内容行数 +- Tabs、Card、Table 在 overflow 时是否仍可操作 + +## 落地顺序 + +后续新增或重构后台页时,优先按这个顺序设计: + +1. 先定义主工作区 +2. 再确定哪些模块必须常驻可见 +3. 最后再做样式和视觉层次 + +简单说: + +- 先保证空间分配正确 +- 再处理滚动边界 +- 最后再做美化 diff --git a/frontend/package.json b/frontend/package.json index 6b306618..3e9f19df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.4", + "version": "0.24.0", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4457f0a..a5a8c337 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 83fe521e..1820a7d9 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -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: , label: '数据源' }, { key: '/data', icon: , label: '采集数据' }, { key: '/bgp', icon: , label: 'BGP观测' }, + { key: '/playground', icon: , label: 'AI Playground' }, { key: '/users', icon: , label: '用户管理' }, { key: '/settings', icon: , label: '系统配置' }, ] diff --git a/frontend/src/index.css b/frontend/src/index.css index 606c1ec5..efcce359 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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, diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx new file mode 100644 index 00000000..684b540e --- /dev/null +++ b/frontend/src/pages/Playground/Playground.tsx @@ -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 +} + +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() + const [messageApi, contextHolder] = message.useMessage() + const [statusLoading, setStatusLoading] = useState(false) + const [analyzing, setAnalyzing] = useState(false) + const [providerStatus, setProviderStatus] = useState(null) + const [analysis, setAnalysis] = useState(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(`${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( + `${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 ( + + {contextHolder} +
+
+
+ AI Playground + + 在前端测试 AI 能力,但实际请求仍统一经由主后端转发到 AI Provider。 + +
+
+ +
+
+
+ } + onClick={() => void loadProviderStatus(true)} + aria-label="刷新 Provider 状态" + className="playground-card__icon-button" + /> + )} + > +
+
+ + {providerStatus ? ( +
+
+ Provider + {providerStatus.provider || '-'} +
+
+ 模型 + {providerStatus.model || '-'} +
+
+ API + {providerStatus.api || '-'} +
+
+ 状态 +
+ + {providerStatus.enabled ? 'enabled' : 'disabled'} + + + {providerStatus.configured ? 'configured' : 'not configured'} + +
+
+
+ Base URL + + {providerStatus.base_url || '-'} + +
+
+ ) : ( + + )} +
+
+
+
+ + setHelpExpanded(Array.isArray(keys) ? keys.includes('help') : keys === 'help')} + items={[ + { + key: 'help', + label: '测试说明', + children: ( + + 当前链路: + frontend /playground + {' -> '} + backend /api/v1/ai/* + {' -> '} + aiprovider /v1/* + + )} + /> + ), + }, + ]} + /> +
+ + + setActiveTab(key as 'request' | 'result')} + className="playground-tabs" + items={[ + { + key: 'request', + label: '请求', + children: ( +
+
+ + + + + + + + +
+ + + + + + + +
+ +
+ + +
+ +
+ ), + }, + { + key: 'result', + label: '结果', + children: ( +
+ {analyzing ? ( +
+ + AI 正在生成结果... +
+ ) : analysis ? ( + +
+ {analysis.provider} + {analysis.model} +
+
+
{analysis.content}
+
+ {analysis.text_blocks.length ? ( +
+ 文本块 +
+ {analysis.text_blocks.map((block, index) => ( + +
{block}
+
+ ))} +
+
+ ) : null} +
+ ) : ( + + )} +
+ ), + }, + ]} + /> +
+
+
+
+
+ ) +} + +export default Playground diff --git a/planet.sh b/planet.sh index 712a7a27..7f7df5e8 100755 --- a/planet.sh +++ b/planet.sh @@ -1236,8 +1236,8 @@ start() { log_success "启动完成" log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" + log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" - log_note "智能星球AI: http://localhost:${AI_PROVIDER_PORT}/chat" } stop() { diff --git a/pyproject.toml b/pyproject.toml index ccf57a26..32deb601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.23.4" +version = "0.24.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 73db0fef..7ef15c78 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.23.4" +version = "0.24.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },