diff --git a/VERSION b/VERSION index d21d277b..af2dabf3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.0 +0.25.1 diff --git a/backend/app/services/playground_chat_service.py b/backend/app/services/playground_chat_service.py index 9ce32970..d4df9dc4 100644 --- a/backend/app/services/playground_chat_service.py +++ b/backend/app/services/playground_chat_service.py @@ -44,6 +44,56 @@ class _ActiveRun: _ACTIVE_RUNS: dict[str, _ActiveRun] = {} +async def _get_session_by_key( + db: AsyncSession, + *, + user_id: int, + session_key: str, +) -> PlaygroundSession | None: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == session_key, + ) + ) + return result.scalar_one_or_none() + + +async def _require_session( + db: AsyncSession, + *, + user_id: int, + session_key: str, +) -> PlaygroundSession: + session = await _get_session_by_key(db, user_id=user_id, session_key=session_key) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") + return session + + +async def _require_visible_message( + db: AsyncSession, + *, + user_id: int, + public_id: str, + role: str | None = None, +) -> PlaygroundMessage: + conditions = [ + PlaygroundMessage.user_id == user_id, + PlaygroundMessage.public_id == public_id, + PlaygroundMessage.is_visible.is_(True), + ] + if role is not None: + conditions.append(PlaygroundMessage.role == role) + + result = await db.execute(select(PlaygroundMessage).where(*conditions)) + message = result.scalar_one_or_none() + if message is None: + detail = "User message not found" if role == "user" else "Playground message not found" + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) + return message + + def _message_to_record(message: PlaygroundMessage, parent_public_id: str | None = None) -> PlaygroundMessageRecord: return PlaygroundMessageRecord( id=message.public_id, @@ -142,18 +192,26 @@ async def get_thread( user_id: int, session_key: str, ) -> PlaygroundThreadResponse | None: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == session_key, - ) - ) - session = result.scalar_one_or_none() + session = await _get_session_by_key(db, user_id=user_id, session_key=session_key) if session is None: return None return await _build_thread_response(db, session=session) +async def _build_action_response( + db: AsyncSession, + *, + session: PlaygroundSession, + active_message_id: str | None = None, +) -> PlaygroundMessageActionResponse: + thread = await _build_thread_response(db, session=session) + return PlaygroundMessageActionResponse( + session=thread.session, + messages=thread.messages, + active_message_id=active_message_id, + ) + + def _collect_constraints(raw_constraints: str) -> list[str]: return [item.strip() for item in raw_constraints.split("\n") if item.strip()] @@ -190,6 +248,31 @@ async def _set_session_state( return session +def _spawn_assistant_run( + *, + user_id: int, + session_id: int, + session_key: str, + user_message_id: int, + assistant_message_id: int, + assistant_public_id: str, + payload: PlaygroundMessageCreateRequest, + provider_client: AIProviderClient, +) -> None: + task = asyncio.create_task( + _run_assistant_message( + user_id=user_id, + session_id=session_id, + session_key=session_key, + user_message_id=user_message_id, + assistant_message_id=assistant_message_id, + payload=payload, + provider_client=provider_client, + ) + ) + _ACTIVE_RUNS[assistant_public_id] = _ActiveRun(task) + + async def create_turn( db: AsyncSession, *, @@ -252,23 +335,20 @@ async def create_turn( await db.refresh(user_message) await db.refresh(assistant_message) - task = asyncio.create_task( - _run_assistant_message( - user_id=user_id, - session_id=session.id, - session_key=payload.session_key, - user_message_id=user_message.id, - assistant_message_id=assistant_message.id, - payload=payload, - provider_client=provider_client, - ) + _spawn_assistant_run( + user_id=user_id, + session_id=session.id, + session_key=payload.session_key, + user_message_id=user_message.id, + assistant_message_id=assistant_message.id, + assistant_public_id=assistant_message.public_id, + payload=payload, + provider_client=provider_client, ) - _ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, + return await _build_action_response( + db, + session=session, active_message_id=assistant_message.public_id, ) @@ -303,23 +383,20 @@ async def _create_assistant_retry_turn( await db.refresh(session) await db.refresh(assistant_message) - task = asyncio.create_task( - _run_assistant_message( - user_id=user_id, - session_id=session.id, - session_key=payload.session_key, - user_message_id=user_message.id, - assistant_message_id=assistant_message.id, - payload=payload, - provider_client=provider_client, - ) + _spawn_assistant_run( + user_id=user_id, + session_id=session.id, + session_key=payload.session_key, + user_message_id=user_message.id, + assistant_message_id=assistant_message.id, + assistant_public_id=assistant_message.public_id, + payload=payload, + provider_client=provider_client, ) - _ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, + return await _build_action_response( + db, + session=session, active_message_id=assistant_message.public_id, ) @@ -330,34 +407,11 @@ async def stop_message( user_id: int, payload: PlaygroundMessageStopRequest, ) -> PlaygroundMessageActionResponse: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == payload.session_key, - ) - ) - session = result.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") - - result = await db.execute( - select(PlaygroundMessage).where( - PlaygroundMessage.user_id == user_id, - PlaygroundMessage.public_id == payload.message_id, - PlaygroundMessage.is_visible.is_(True), - ) - ) - message = result.scalar_one_or_none() - if message is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground message not found") + session = await _require_session(db, user_id=user_id, session_key=payload.session_key) + message = await _require_visible_message(db, user_id=user_id, public_id=payload.message_id) if message.status not in {"pending", "thinking", "answering"}: - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, - active_message_id=None, - ) + return await _build_action_response(db, session=session) active_run = _ACTIVE_RUNS.get(message.public_id) if active_run is not None: @@ -371,12 +425,7 @@ async def stop_message( await db.commit() await db.refresh(message) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, - active_message_id=None, - ) + return await _build_action_response(db, session=session) async def resend_turn( @@ -386,27 +435,13 @@ async def resend_turn( payload: PlaygroundMessageResendRequest, provider_client: AIProviderClient, ) -> PlaygroundMessageActionResponse: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == payload.session_key, - ) + session = await _require_session(db, user_id=user_id, session_key=payload.session_key) + user_message = await _require_visible_message( + db, + user_id=user_id, + public_id=payload.user_message_id, + role="user", ) - session = result.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") - - result = await db.execute( - select(PlaygroundMessage).where( - PlaygroundMessage.user_id == user_id, - PlaygroundMessage.public_id == payload.user_message_id, - PlaygroundMessage.role == "user", - PlaygroundMessage.is_visible.is_(True), - ) - ) - user_message = result.scalar_one_or_none() - if user_message is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found") later_messages = await db.execute( select(PlaygroundMessage).where( @@ -451,39 +486,20 @@ async def edit_user_message( user_id: int, payload: PlaygroundMessageEditRequest, ) -> PlaygroundMessageActionResponse: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == payload.session_key, - ) + session = await _require_session(db, user_id=user_id, session_key=payload.session_key) + user_message = await _require_visible_message( + db, + user_id=user_id, + public_id=payload.user_message_id, + role="user", ) - session = result.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") - - result = await db.execute( - select(PlaygroundMessage).where( - PlaygroundMessage.user_id == user_id, - PlaygroundMessage.public_id == payload.user_message_id, - PlaygroundMessage.role == "user", - PlaygroundMessage.is_visible.is_(True), - ) - ) - user_message = result.scalar_one_or_none() - if user_message is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found") user_message.content = payload.content.strip() await db.flush() await db.commit() await db.refresh(user_message) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, - active_message_id=None, - ) + return await _build_action_response(db, session=session) async def _append_meta_if_missing(db: AsyncSession, message_id: int, meta_line: str) -> None: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9e9e9421..0fab8218 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,23 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.25.1 + +Released: 2026-04-10 + +### Highlights + +- Cleaned up the first persistent Playground rollout, fixed sidebar submenu persistence to match the intended operator behavior, and added reusable code-hygiene rules to prevent this class of drift from accumulating again. + +### Improved + +- Improved [backend/app/services/playground_chat_service.py](/home/ray/dev/linkong/planet/backend/app/services/playground_chat_service.py) and [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) by extracting repeated lookup, response, and request-action paths into shared helpers, reducing duplicated Playground flow code without changing behavior. +- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding a new `Code Hygiene - MANDATORY` section covering single-source-of-truth state, transitional cleanup, repeated-logic extraction, layout debugging order, and post-feature cleanup expectations. + +### Fixed + +- Fixed [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) so first-level menu expansion now behaves correctly across in-app navigation: `采集与数据` remains the default expanded group after refresh, while manually expanded groups stay open when navigating to their own child routes and reset only on page reload. + ## 0.25.0 Released: 2026-04-10 diff --git a/docs/version-history.md b/docs/version-history.md index cb9dca0b..52ec0498 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.25.0` +- `dev` 当前开发分支历史推导到:`0.25.1` ## Timeline @@ -82,6 +82,7 @@ | `0.24.7` | bugfix | `dev` | `pending` | formalize release workflow and frontend layout constraints with repo rules and a reusable release skill | | `0.24.8` | bugfix | `dev` | `pending` | move BGP brief markdown into a dedicated modal, keep tab content metadata-focused, and constrain modal scrolling to the viewport | | `0.25.0` | feature | `dev` | `89a71e6f` | add persistent backend-backed AI Playground chat state, split alert workspaces into dedicated pages, and establish the situational-awareness foundation for later multi-signal analysis | +| `0.25.1` | bugfix | `dev` | `pending` | clean duplicated Playground flow code, add reusable code-hygiene rules, and fix first-level sidebar menu expansion behavior across route navigation and refresh | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index bd5e9e40..4f80d231 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.25.0", + "version": "0.25.1", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 5a11422f..79724870 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -23,6 +23,8 @@ import packageJson from '../../../package.json' const { Sider, Content } = Layout const { Text } = Typography +const DEFAULT_OPEN_MENU_KEY = 'collection' +let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY] interface AppLayoutProps { children: ReactNode @@ -33,7 +35,7 @@ function AppLayout({ children }: AppLayoutProps) { const navigate = useNavigate() const { user, logout } = useAuthStore() const [collapsed, setCollapsed] = useState(false) - const [openKeys, setOpenKeys] = useState(['collection']) + const [openKeys, setOpenKeys] = useState(cachedOpenKeys) const showBanner = true const appVersion = `v${packageJson.version}` @@ -91,6 +93,11 @@ function AppLayout({ children }: AppLayoutProps) { return location.pathname }, [location.pathname]) + const updateOpenKeys = (nextKeys: string[]) => { + cachedOpenKeys = nextKeys + setOpenKeys(nextKeys) + } + return ( { setCollapsed(nextCollapsed) if (nextCollapsed) { - setOpenKeys([]) + return + } + if (!openKeys.length) { + updateOpenKeys([DEFAULT_OPEN_MENU_KEY]) } }} className="dashboard-sider" @@ -129,7 +139,7 @@ function AppLayout({ children }: AppLayoutProps) { openKeys={collapsed ? [] : openKeys} items={menuItems} onOpenChange={(keys) => { - setOpenKeys(keys as string[]) + updateOpenKeys(keys as string[]) }} onClick={({ key }) => { if (key !== selectedKey) { diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 1f20417f..60198463 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { ApiOutlined, ArrowDownOutlined, ArrowUpOutlined, BorderOutlined, CopyOutlined, EditOutlined, InfoCircleOutlined, RedoOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons' +import { ApiOutlined, ArrowDownOutlined, ArrowUpOutlined, BorderOutlined, CopyOutlined, EditOutlined, InfoCircleOutlined, RedoOutlined, RobotOutlined, SettingOutlined, SyncOutlined, UserOutlined } from '@ant-design/icons' import { Alert, Avatar, @@ -19,7 +19,6 @@ import { message, } from 'antd' import axios from 'axios' -import { RobotOutlined, UserOutlined } from '@ant-design/icons' import AppLayout from '../../components/AppLayout/AppLayout' import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' @@ -31,6 +30,17 @@ const PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY = 'playground-provider-status' const PLAYGROUND_REMOTE_SESSION_KEY = 'default' const THREAD_POLL_INTERVAL_MS = 1200 const SCROLL_BOTTOM_THRESHOLD_PX = 120 +const PLAYGROUND_SYSTEM_MESSAGE: PlaygroundMessage = { + id: 'playground-system-intro', + role: 'system', + title: 'Playground 已就绪', + content: '这里是 AI 测试台。聊天记录与执行状态现在以后台数据库为准,刷新后会恢复到真实进度。', +} +const EMPTY_ANALYSIS_META: AnalysisRunMeta = { + requestId: null, + durationMs: null, + completedAt: null, +} interface AIProviderStatus { provider: string @@ -217,22 +227,11 @@ function Playground() { const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].values.objective) const [constraints, setConstraints] = useState(PLAYGROUND_PRESETS[0].values.constraints || '') const [inputValue, setInputValue] = useState(PLAYGROUND_PRESETS[0].values.message) - const [messages, setMessages] = useState([ - { - id: 'playground-system-intro', - role: 'system', - title: 'Playground 已就绪', - content: '这里是 AI 测试台。选择一个快速预设,补充输入消息,然后发送一次真实后端链路请求。', - }, - ]) + const [messages, setMessages] = useState([PLAYGROUND_SYSTEM_MESSAGE]) const [analysis, setAnalysis] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [latestAnalysisMessageId, setLatestAnalysisMessageId] = useState(null) - const [analysisMeta, setAnalysisMeta] = useState({ - requestId: null, - durationMs: null, - completedAt: null, - }) + const [analysisMeta, setAnalysisMeta] = useState(EMPTY_ANALYSIS_META) const [requestPending, setRequestPending] = useState(false) const [editingMessageId, setEditingMessageId] = useState(null) const [editingContent, setEditingContent] = useState('') @@ -266,24 +265,46 @@ function Playground() { setInputValue(sessionState.inputValue || '') setHelpExpanded(sessionState.helpExpanded ?? true) const remoteMessages = thread.messages.map(toPlaygroundMessage) - setMessages([ - { - id: 'playground-system-intro', - role: 'system', - title: 'Playground 已就绪', - content: '这里是 AI 测试台。聊天记录与执行状态现在以后台数据库为准,刷新后会恢复到真实进度。', - }, - ...remoteMessages, - ]) + setMessages([PLAYGROUND_SYSTEM_MESSAGE, ...remoteMessages]) setLatestAnalysisMessageId(sessionState.latestAnalysisMessageId || null) setAnalysis((sessionState.analysis as SituationalAnalysisResponse | null) || null) - setAnalysisMeta(sessionState.analysisMeta || { - requestId: null, - durationMs: null, - completedAt: null, + setAnalysisMeta(sessionState.analysisMeta || EMPTY_ANALYSIS_META) + } + + const authHeaders = (includeJson = false): HeadersInit => ({ + ...(includeJson ? { 'Content-Type': 'application/json' } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }) + + const applyActionResponse = (data: { messages: PlaygroundApiMessage[]; session: PlaygroundSessionRecord }) => { + applyThreadSnapshot({ + session: data.session, + messages: data.messages, }) } + const fetchPlaygroundThread = async () => { + const res = await fetch(`${API_BASE_URL}/ai/playground/thread?session_key=${encodeURIComponent(PLAYGROUND_REMOTE_SESSION_KEY)}`, { + headers: authHeaders(), + }) + if (!res.ok) { + throw new Error('THREAD_FETCH_FAILED') + } + return (await res.json()) as PlaygroundThreadRecord | null + } + + const postPlaygroundAction = async (path: string, body: TBody) => { + const res = await fetch(`${API_BASE_URL}${path}`, { + method: 'POST', + headers: authHeaders(true), + body: JSON.stringify(body), + }) + if (!res.ok) { + throw new Error(`PLAYGROUND_ACTION_FAILED:${path}`) + } + return (await res.json()) as { messages: PlaygroundApiMessage[]; session: PlaygroundSessionRecord } + } + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { const container = messagesContainerRef.current if (!container) { @@ -327,20 +348,12 @@ function Playground() { } try { - const res = await fetch(`${API_BASE_URL}/ai/playground/thread?session_key=${encodeURIComponent(PLAYGROUND_REMOTE_SESSION_KEY)}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }) - if (res.ok) { - const thread = (await res.json()) as PlaygroundThreadRecord | null - applyThreadSnapshot(thread) - } + const thread = await fetchPlaygroundThread() + applyThreadSnapshot(thread) } catch { if (!options?.silent) { messageApi.error('Playground 会话加载失败') } - } finally { } } @@ -417,25 +430,11 @@ function Playground() { return } try { - const res = await fetch(`${API_BASE_URL}/ai/playground/messages/stop`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - message_id: activeAssistantMessage.id, - }), - }) - if (!res.ok) { - throw new Error('STOP_FAILED') - } - const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages/stop', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + message_id: activeAssistantMessage.id, }) + applyActionResponse(data) messageApi.warning('已停止生成,已输出内容会保留') } catch { messageApi.error('停止失败,请稍后再试') @@ -455,30 +454,16 @@ function Playground() { setRequestPending(true) try { - const res = await fetch(`${API_BASE_URL}/ai/playground/messages`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - title: trimmedTitle, - objective: trimmedObjective, - constraints, - input: trimmedInput, - selected_preset_key: selectedPreset.key, - help_expanded: helpExpanded, - }), - }) - if (!res.ok) { - throw new Error('SEND_FAILED') - } - const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + title: trimmedTitle, + objective: trimmedObjective, + constraints, + input: trimmedInput, + selected_preset_key: selectedPreset.key, + help_expanded: helpExpanded, }) + applyActionResponse(data) setRequestPending(false) void refreshThread({ silent: true }) } catch { @@ -521,25 +506,11 @@ function Playground() { return } try { - const res = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - user_message_id: entry.id, - }), - }) - if (!res.ok) { - throw new Error('RESEND_FAILED') - } - const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages/resend', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, }) + applyActionResponse(data) messageApi.success('已重试,并清理该消息之后的旧分支') } catch { messageApi.error('重试失败,请稍后再试') @@ -569,40 +540,16 @@ function Playground() { try { setEditSaving(true) - const editRes = await fetch(`${API_BASE_URL}/ai/playground/messages/edit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - user_message_id: entry.id, - content: trimmed, - }), + await postPlaygroundAction('/ai/playground/messages/edit', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + content: trimmed, }) - if (!editRes.ok) { - throw new Error('EDIT_FAILED') - } - const resendRes = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - user_message_id: entry.id, - }), - }) - if (!resendRes.ok) { - throw new Error('RESEND_AFTER_EDIT_FAILED') - } - const data = (await resendRes.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages/resend', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, }) + applyActionResponse(data) setEditingMessageId(null) setEditingContent('') setEditSaving(false) diff --git a/pyproject.toml b/pyproject.toml index 3add7568..453f4625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.25.0" +version = "0.25.1" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/rules.md b/rules.md index 68c78346..b4a2791f 100644 --- a/rules.md +++ b/rules.md @@ -225,6 +225,21 @@ class BaseCollector: - Feature flags for incomplete features - Use config files for environment-specific settings +## Code Hygiene - MANDATORY + +- Maintain a single source of truth for business state. Frontend temporary state, cached state, and persisted backend state must not evolve into parallel truths. +- Transitional paths are temporary. Once a new implementation is stable, remove old branches, old interfaces, old mocks, and compatibility layers instead of letting them linger. +- Repeated logic must be extracted. If request flow, response handling, auth/header assembly, validation, or state reconciliation appears more than once or twice, promote it into a helper or shared layer. +- Repeated backend resource lookup and response assembly must be centralized. Avoid scattering the same `load -> validate -> transform -> respond` pattern across multiple handlers or services. +- Default values, system prompts, placeholder structures, and other fixed constants must be centralized rather than re-declared in multiple states or code paths. +- When debugging layout, scrolling, or overflow issues, first inspect structural ownership of height, width, and overflow before applying isolated style patches. +- Distinct interaction modes must have explicit structure and state semantics. View, edit, loading, error, stopped, and retry states should not be forced through the exact same markup or logic path. +- Presentation state must not pretend to be business state. UI animation, phase labels, and optimistic display layers must defer to real persisted or backend task state when it exists. +- Responsive adaptations must preserve the primary action path. Reflow is fine; losing or displacing the main user action is not. +- After large feature commits, perform an explicit cleanup pass for dead code, temporary branches, duplicated helpers, stale interfaces, and naming drift before considering the work complete. +- If a file or module starts accumulating repeated patterns or mixed responsibilities, stop and refactor before continuing to add more features on top. +- Public interfaces, persisted fields, and state structures must have a current owner and caller. If something is no longer used, delete it instead of keeping it “just in case”. + --- ## Query Performance - MANDATORY diff --git a/uv.lock b/uv.lock index b24b26e0..08656abb 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.25.0" +version = "0.25.1" source = { virtual = "." } dependencies = [ { name = "aiofiles" },