commit 66de96d9e45593b18723d5e895d2501041be3b1c Author: Huarch Date: Fri Jul 10 16:16:17 2026 +0800 feat: initialize drainage network frontend diff --git a/.agents/skills/drainage-network-design/SKILL.md b/.agents/skills/drainage-network-design/SKILL.md new file mode 100644 index 0000000..7fdf434 --- /dev/null +++ b/.agents/skills/drainage-network-design/SKILL.md @@ -0,0 +1,37 @@ +--- +name: drainage-network-design +description: Use when designing, redesigning, or adjusting visual styles, layout, motion, spacing, typography, map panels, workbench UI, Agent UI, tool panels, timeline/list/detail panels, or any component styling in drainage-network. Also use when a user makes substantial component UI changes that should be captured back into DESIGN.md. +--- + +# Drainage Network Design + +This skill keeps UI work aligned with the project design guide. + +## Required Workflow + +1. Before changing component layout, styling, motion, density, copy hierarchy, or interaction shape, read `DESIGN.md` from the `drainage-network` repo root. +2. Apply the guide as the source of truth for map-first layout, command instruments, surface opacity, radius, typography, motion, and panel behavior. +3. Prefer existing local style helpers and component patterns over new one-off visual systems. +4. After implementing a substantial UI adjustment, update `DESIGN.md` when the change establishes a reusable rule, corrects an outdated rule, or resolves a repeated design tradeoff. +5. Keep `DESIGN.md` updates behavior-level and reusable. Do not document one-off class names unless they represent an intentional design token or pattern. + +## What Counts As Substantial + +Update `DESIGN.md` when the work changes any of these: + +- A reusable component pattern, such as collapsed-to-expanded detail panels, timeline rows, persistent map instruments, or Agent decision surfaces. +- Visual hierarchy rules for shells, readable surfaces, rows, detail cards, or action areas. +- Motion rules, including when content should enter, whether lists should remount, or how scroll position is preserved. +- Information architecture, such as what appears in collapsed versus expanded states. +- Common responsive behavior for map overlays or side panels. + +Do not update `DESIGN.md` for small bug fixes, minor spacing tweaks, copy corrections, or purely local implementation cleanup unless the guide is already contradicted by the current UI. + +## Implementation Defaults + +- Keep the map as the primary canvas. Floating UI should behave like operational instruments, not generic dashboard cards. +- Use `surface-map-control` treatment for persistent map instruments and readable inner surfaces for dense text. +- Avoid decorative chrome, oversized headings, nested cards, and visual effects without operational meaning. +- Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels. +- Preserve list identity and scroll position when a collapsed list expands into a detail workspace. +- Validate frontend UI changes with `pnpm lint` and `pnpm exec tsc --noEmit`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected. diff --git a/.agents/skills/drainage-network-design/agents/openai.yaml b/.agents/skills/drainage-network-design/agents/openai.yaml new file mode 100644 index 0000000..882710f --- /dev/null +++ b/.agents/skills/drainage-network-design/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Next WebGIS Design" + short_description: "Align component UI work with DESIGN.md." + default_prompt: "Use $drainage-network-design to update this component while following the project design guide." + +policy: + allow_implicit_invocation: true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d2aabc --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here +NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4a8916 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.env.local +.next/ +node_modules/ +tsconfig.tsbuildinfo +供水管网_Agent_WebGIS_设计与技术框架.md +现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md +todo.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7d86676 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository is a Next.js App Router business system for an Agent-driven drainage-network WebGIS. + +- `app/`: route entrypoints only. Keep `page.tsx`, `layout.tsx`, route-level loading/error, and metadata thin. +- `lib/`: shared utilities that are not owned by a feature. +- `features/map/core/`: reusable map UI and generic map helpers only. This layer should not know drainage-network business layer IDs, simulation scenarios, or Agent workflows. +- `features/workbench/`: the drainage-network WebGIS workbench. Put drainage-network sources, layers, simulation overlays, feature adapters, and workbench-specific map interactions here. +- `features/agent/`: Agent-facing task data, panels, and action recommendations. Agent code should produce typed action data instead of mutating the map or UI directly. +- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features. +- Root markdown files are product/design references and should not be treated as runtime code. + +## Build, Test, and Development Commands + +Use `pnpm`. + +```bash +pnpm dev +``` +Starts the local development server at `http://localhost:3000`. + +```bash +pnpm build +``` +Creates a production Next.js build. Stop `pnpm dev` before running this to avoid concurrent `.next` writes. + +```bash +pnpm exec tsc --noEmit +``` +Runs TypeScript validation without emitting files. + +## Coding Style & Naming Conventions + +- Use TypeScript and React function components. Prefer explicit props types over inline anonymous object types for reusable components. +- Use two-space indentation. Keep imports ordered: external packages first, then `@/` aliases, then local relative imports. +- Use kebab-case for filenames and folders: `feature-insight-panel.tsx`, `use-map-selection.ts`, `drainage-network-layers.ts`. +- Use PascalCase for React components and component types: `FeatureInsightPanel`, `MapWorkbenchPage`. +- Use camelCase for variables, functions, object fields, and non-component exports: `selectedFeatureId`, `createWaterNetworkSources`. +- Use `useXxx` for hooks: `useWorkbenchMap`, `usePanelLayout`. +- Use PascalCase type names with clear suffixes: `MapViewState`, `AgentUiResult`, `LayerConfig`, `FeatureInsightProps`. +- Use UPPER_SNAKE_CASE only for stable global constants: `WATER_NETWORK_BOUNDS`, `SOURCE_LAYERS`. +- Use named exports for feature modules. Avoid default exports except for Next.js route files under `app/`. +- Keep MapLibre sources, layers, camera helpers, and interactions outside presentational React components. +- Prefer typed configuration objects for GeoServer layers, Mapbox basemap settings, and Agent actions. + +## Code Organization Style + +Use thin orchestration components. A page component may compose panels, hooks, and map modules, but should not contain large mock datasets, layer specs, event handlers, and UI markup in one file. + +Recommended future pattern: + +```txt +features/map/core/ + components/ + base-layers-control.tsx + scale-line.tsx + toolbar.tsx + zoom.tsx + legend.tsx + layer-control.tsx + hooks/ + use-map-camera.ts + use-layer-visibility.ts + use-map-selection.ts + map/ + style.ts + layer-utils.ts + +features/workbench/ + map-workbench-page.tsx + components/ + workbench-top-bar.tsx + simulation-dock.tsx + simulation-result-panel.tsx + hooks/use-workbench-map.ts + map/sources.ts + map/layers.ts + map/annotation-layers.ts + state/types.ts + +features/agent/ + components/agent-assistant-panel.tsx + data/agent-task.ts + types.ts +``` + +Agent actions should be data, not imperative UI mutations. Let the UI/map state layer validate, preview, confirm, apply, and roll back actions. + +Keep the growth boundary clear: +- `features/map/core` owns generic map capabilities such as base layer controls, toolbars, legends, layer controls, camera helpers, and reusable selection primitives. +- `features/workbench` owns drainage-network domain behavior such as pipe, manhole, outfall, and pump-station sources, simulation overlays, feature detail adapters, scenario controls, and business-specific interactions. +- `features/agent` owns task reasoning UI and typed recommendations. It should communicate with the workbench through action objects such as `preview-impact`, `locate-feature`, or `set-layer-visible`. + +## Testing Guidelines + +There is no formal test suite yet. For now, required checks are: + +```bash +pnpm exec tsc --noEmit +pnpm build +``` + +When tests are added, colocate feature tests near the feature module or place shared integration tests under `tests/`. Name tests after behavior, for example `map-selection.test.ts`. + +## Commit & Pull Request Guidelines + +This directory currently has no Git history, so no existing commit convention can be inferred. Use concise, imperative commit messages such as: + +```txt +Add direct GeoServer MVT sources +Refactor map workbench shell +``` + +Pull requests should include: +- Summary of user-visible changes. +- Notes on map/data source changes. +- Screenshots for UI changes. +- Verification commands run. + +## Security & Configuration Tips + +Store local secrets in `.env.local`. Do not commit real tokens. The Mapbox token is read from: + +```env +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= +``` + +GeoServer MVT layers are loaded directly from `geoserver.waternetwork.cn`; confirm CORS remains enabled before removing fallbacks or changing hosts. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..452df1d --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,823 @@ +# Immersive Drainage Network WebGIS Design + +## Overview + +This product is an immersive command workspace for drainage network dispatch, simulation, and Agent-assisted decision making. The interface should feel like entering a live operational map, not browsing a dashboard or a marketing page. + +The map is the primary canvas. Pipes, junctions, incidents, affected districts, scenarios, and Agent recommendations are the visual subject. UI panels exist to help the operator understand the situation, preview consequences, confirm actions, and roll back decisions. They must never compete with the spatial context. + +The visual direction is restrained technical immersion: + +- Full-screen spatial workspace by default. +- Clear operational hierarchy over decorative spectacle. +- Light engineering map as the default canvas. +- Floating translucent command surfaces with strong readability. +- Blue as the primary action and selection color. +- Green/cyan for normal network flow, orange for risk, red for incident and impact, purple only for model or Agent inference. +- Motion used to explain spatial change, simulation progress, and recommended actions. + +The design should be suitable for long daily use by dispatchers and engineers while still feeling modern, spatial, and intelligent. + +## Experience Principles + +### Map First + +The first viewport is always the working map. Do not introduce a landing page, marketing hero, decorative splash screen, or card-heavy overview before the operator reaches the map. + +The map must answer: + +- What is happening now? +- Where is it happening? +- Which assets, districts, and users are affected? +- What actions are recommended? +- What will change if an action is applied? + +### UI As Command Instruments + +Panels, controls, and toolbars should feel like instruments floating over the map. They should be available when needed, collapsible when not needed, and positioned to preserve the operator's spatial awareness. + +Fixed UI must avoid long-term obstruction of critical map objects. The map camera should reserve safe padding for open panels, especially during locate, fit bounds, and scenario preview operations. + +### Explain Before Acting + +Agent and simulation features must expose a decision chain: + +1. Detect or receive task. +2. Explain current evidence. +3. Recommend one or more actions. +4. Preview spatial and service impact. +5. Ask for confirmation. +6. Apply with progress feedback. +7. Offer rollback or audit trail. + +Never let the Agent directly mutate map state or operational state without an explicit preview and confirmation path. + +### Dense, Not Crowded + +This is a command workspace, so information density is expected. Density should come from compact typography, clear grouping, and predictable layout. It should not come from tiny illegible controls, overlapping surfaces, or excessive decoration. + +### Restraint Over Spectacle + +The product may feel technical and immersive, but it should not become a generic "sci-fi dashboard." Avoid gratuitous glow, animated circuit lines, decorative gradients, 3D ornaments, and full-screen dark neon themes unless a future mode explicitly requires them. + +## Visual Language + +### Tone + +The interface should feel: + +- Operational +- Spatial +- Calm under pressure +- Trustworthy +- Technically precise +- AI-assisted but human-controlled + +It should not feel: + +- Like a marketing site +- Like a generic admin dashboard +- Like a decorative data wall +- Like a chat app with a map attached + +### Canvas + +Use a light engineering-map canvas as the default environment. The map may use a real basemap, a vector tile basemap, or a fallback grid, but it should remain quiet enough for drainage-network layers to dominate. + +Recommended canvas colors: + +| Token | Value | Use | +|---|---:|---| +| `canvas-map` | `#eaf1f8` | Fallback map background | +| `canvas-map-grid` | `rgba(37, 99, 235, 0.06)` | Subtle engineering grid | +| `canvas-page` | `#f8fafc` | Non-map support pages | +| `canvas-panel` | `rgba(255, 255, 255, 0.76)` | Default floating panel | +| `canvas-panel-strong` | `rgba(255, 255, 255, 0.92)` | Dense text or form surface | +| `canvas-panel-soft` | `rgba(255, 255, 255, 0.58)` | Low-density spatial overlay | +| `surface-overlay` | `rgba(255, 255, 255, 0.72-0.86)` | Floating menu or compact popover shell | +| `surface-readable` | `rgba(255, 255, 255, 0.90-0.96)` | Readable rows, menu headers, and content blocks inside translucent shells | +| `surface-map-control` | `rgba(255, 255, 255, 0.92)` | Always-visible and persistent map instruments: toolbar, zoom, scaleline, Agent shell, scheduled-condition shell | +| `surface-tool-panel` | `rgba(255, 255, 255, 0.78)` | Toolbar function panel outer shell | + +### Color System + +#### Core UI Colors + +| Token | Value | Use | +|---|---:|---| +| `action-blue` | `#2563eb` | Primary actions, selected tools, active scenario | +| `action-blue-hover` | `#1d4ed8` | Hover and pressed primary action | +| `action-blue-soft` | `#dbeafe` | Selected background, low-emphasis active state | +| `ink` | `#0f172a` | Primary text | +| `ink-secondary` | `#334155` | Secondary labels and values | +| `ink-muted` | `#64748b` | Descriptions, metadata | +| `ink-disabled` | `#94a3b8` | Disabled controls | +| `border-soft` | `rgba(255, 255, 255, 0.62)` | Glass panel border | +| `border-strong` | `rgba(148, 163, 184, 0.34)` | Dense control border | + +#### Operational Colors + +| Token | Value | Use | +|---|---:|---| +| `network-major` | `#0477bf` | Major pipes, DN600 and above | +| `network-medium` | `#0aa6a6` | Medium pipes, DN300-DN600 | +| `network-minor` | `#3dbf7f` | Minor pipes, below DN300 | +| `network-closed` | `#9ca3af` | Closed or inactive assets | +| `demand-low` | `#70c1b3` | Low demand junctions | +| `demand-medium` | `#247ba0` | Medium demand junctions | +| `demand-high` | `#f25f5c` | High demand junctions | +| `risk` | `#ff7a45` | Risk preview, warning glow | +| `incident` | `#ef4444` | Burst, outage, critical impact | +| `success` | `#22c55e` | Improved scenario, completed action | +| `agent` | `#7c3aed` | Agent/model inference only | + +Color must carry meaning. Do not use orange, red, green, or purple as decoration. + +### Typography + +Use a system font stack that performs well for Chinese and English operational UI: + +```css +font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; +``` + +Avoid negative letter spacing. This product should optimize for scanning dense operational data, not editorial display styling. + +| Token | Size | Weight | Line Height | Use | +|---|---:|---:|---:|---| +| `title-lg` | 24px | 600 | 1.25 | Rare page or mode title | +| `title-md` | 18px | 600 | 1.35 | Panel title | +| `title-sm` | 16px | 600 | 1.35 | Section title | +| `body` | 14px | 400 | 1.55 | Default UI text | +| `body-strong` | 14px | 600 | 1.45 | Important labels and values | +| `caption` | 12px | 400 | 1.45 | Metadata, descriptions | +| `caption-strong` | 12px | 600 | 1.35 | Status labels, compact headers | +| `metric` | 24px | 600 | 1.1 | Key numeric metric | +| `map-label` | 12-14px | 600 | 1.2 | Map labels with halo | + +Use large display typography sparingly. In the command workspace, oversized headings waste spatial context. + +### Spacing + +Use a 4px base unit with 8px as the main layout rhythm. + +| Token | Value | Use | +|---|---:|---| +| `space-1` | 4px | Micro gaps | +| `space-2` | 8px | Control gaps | +| `space-3` | 12px | Compact groups | +| `space-4` | 16px | Panel padding | +| `space-5` | 20px | Large panel padding | +| `space-6` | 24px | Major groups | +| `space-8` | 32px | Panel separation | + +Prefer compact layouts with predictable alignment. Do not nest cards inside cards unless the inner card is a repeated data item or a modal-like focus surface. + +### Radius + +| Token | Value | Use | +|---|---:|---| +| `rounded-sm` | 2px | Tiny state markers and very small swatches | +| `rounded-lg` | 8px | Icon cells and compact visual anchors | +| `rounded-xl` | 12px | Standard rows, readable blocks, compact controls, toolbar rails | +| `rounded-2xl` | 16px | Major floating panels and toolbar function panels | +| `rounded-full` | 9999px | Status pills, avatars, scenario chips | + +Rounded corners should imply function. Do not make every object pill-shaped. + +Use a clear radius hierarchy over the map: + +- Major floating panels, including toolbar function panels, header dropdown menus, result panels, simulation panels, and feature detail popovers, use `rounded-2xl`. +- Readable content blocks inside a major panel use `rounded-xl`. +- Standard row buttons and compact control shells use `rounded-xl`. +- Icon cells use `rounded-lg`, so they feel related to row buttons without becoming pill-like. +- Always-visible compact instruments such as toolbar rails, zoom, floating basemap controls, and scaleline use `rounded-xl`. +- Avoid arbitrary radius utilities such as `rounded-[14px]` unless a map-specific geometry requires a one-off value. Prefer Tailwind's default radius scale so inner and outer corners remain visually compatible. + +### Elevation + +Elevation should communicate interaction depth over the map. + +| Level | Treatment | Use | +|---|---|---| +| `map` | No shadow | Basemap and business layers | +| `control` | White 90-96%, soft border, small shadow | Zoom, basemap, tool buttons, persistent command shells | +| `panel` | White 58-82%, backdrop blur, blue-gray shadow | Toolbar function panels, temporary result panels, layer panels | +| `focus` | White 92-98%, stronger border and shadow | Popover, confirmation, detailed forms | +| `modal` | Scrim plus focused surface | Destructive or irreversible decisions | + +Recommended shadows: + +- `control`: `0 14px 34px rgba(15, 23, 42, 0.18)` +- `panel`: `0 24px 70px rgba(30, 64, 115, 0.20)` +- `focus`: `0 30px 90px rgba(15, 23, 42, 0.26)` + +Use `backdrop-filter: blur(20px)` only where the panel remains readable. If the map beneath is visually busy, increase surface opacity instead of increasing blur. + +#### Transparency Layering + +Use consistent opacity tiers for floating UI over the map: + +- Outer shells such as dropdown menus, compact popovers, and temporary command surfaces should use `surface-overlay` around `rgba(255, 255, 255, 0.72-0.86)`. +- Persistent command instruments that remain visible while the operator works, such as the Agent shell and scheduled-condition shell, should use `surface-map-control` at `rgba(255, 255, 255, 0.92)` rather than a low-opacity panel shell. +- Inner readable content such as menu headers, selectable rows, status groups, and form blocks should use `surface-readable` around `rgba(255, 255, 255, 0.90-0.96)`. +- Dense text, destructive confirmations, and detailed forms may use `canvas-panel-strong` around `rgba(255, 255, 255, 0.92)` when legibility matters more than map transparency. +- Do not put long text directly on a low-opacity shell. Give it a local readable surface, or increase the shell opacity. +- Keep readable content at least as opaque as its surrounding shell. Labels, values, and commands should sit on the more readable layer, not directly on a lower-opacity shell. + +### Motion + +Motion should make operational change understandable. + +Use motion for: + +- Locating an asset on the map. +- Previewing affected areas. +- Playing simulation time. +- Expanding or collapsing command panels. +- Showing Agent action preview and confirmation state. +- Drawing attention to new incidents or warnings. + +Avoid motion for: + +- Decorative background movement. +- Constant pulsing on non-critical objects. +- Large parallax effects. +- Slow transitions that delay dispatch work. + +Recommended durations: + +| Token | Duration | Use | +|---|---:|---| +| `fast` | 120-160ms | Button, hover, small controls | +| `normal` | 180-260ms | Panel expand/collapse | +| `map-camera` | 500-700ms | Fit bounds, locate feature | +| `simulation-step` | 300-500ms | Time-step change | + +Workbench panel motion should use `180ms` as the default speed when the component changes structural state, such as showing, hiding, expanding, collapsing, or changing columns. Use `cubic-bezier(0.22, 1, 0.36, 1)` for entrance and expansion, and `cubic-bezier(0.5, 0, 0.2, 1)` for exit. Keep micro-interactions such as hover states, row highlights, icon rotation, and compact button feedback at about `150ms`. + +For detail content that appears after a structural panel transition starts, use a short fade of about `140-150ms` with no long delay. A delay around `60ms` is enough for the content to feel attached to the layout motion; avoid waiting until the full panel transition is finished unless the content would otherwise overlap or jump. + +Toolbar function panels should use a fast, quiet entrance and exit: fade in/out, move 4-8px from the tool rail, and optionally scale between `0.95-1`. Keep the animation around `150ms` so tool switching feels responsive. + +Scaleline motion should stay informational. Only the internal scale bar width may transition over about `120-200ms`; do not animate the outer auto-sized container or coordinate text. + +For panels that expand from a compact list into a detail workspace, use the Collapsible Detail Panels pattern below: animate structure first, then reveal detail content after layout is stable. + +## Immersive Layout Model + +### Primary Regions + +The workspace should be composed from five persistent regions: + +| Region | Role | Behavior | +|---|---|---| +| Map Canvas | Primary spatial context | Always visible, full viewport | +| Command Top Bar | System state | Thin, translucent, status-oriented | +| Agent Command Panel | Reasoning and recommended actions | Left side, collapsible, task-focused | +| Analysis / Layer Rail | Tools, layers, results | Right side, icon-first, panels open on demand | +| Simulation Timeline | Scenario time and playback | Bottom dock, visible during simulation | + +### Command Top Bar + +The top bar is a status strip, not a marketing header. + +It should contain: + +- Product or workspace mark. +- Current data time. +- Model or scenario version. +- Active scenario. +- Alert count and severity. +- User/session controls. + +It should not contain: + +- Large page title. +- Promotional copy. +- Decorative hero content. +- Navigation unrelated to the current operation. + +Recommended height: 56-68px. + +Dropdown menus opened from the top bar behave as floating panels, not compact controls. Use `rounded-2xl` for the outer menu, `rounded-xl` for readable menu header blocks and rows, and `rounded-lg` for icon cells. + +### Collapsible Detail Panels + +Use this pattern for persistent map instruments that start as compact lists and expand into a wider inspection surface. + +- Keep the compact state focused on the primary thing the operator is already scanning. Hide secondary groups until expanded if they would distract from the compact task. +- Preserve the primary list DOM and scroll container across collapse and expansion. Do not replace a collapsed list with a separate expanded list if the user is expected to continue reading the same records. +- Expand by changing shell width and internal column widths, not by pushing content vertically into place. +- Delay detail content entrance until the width transition is complete. A short fade is acceptable after the layout is stable; vertical slide-in for dense text is not. +- Keep the list column width and row typography consistent between collapsed and expanded states unless the task changes substantially. +- The expanded state may add a left or right detail region, but the list should remain in a predictable position so selection context is not lost. + +### Agent Command Panel + +The Agent panel is the main decision companion. It should make the Agent auditable and controllable. + +On desktop, the Agent panel may be wider than a utility side panel because it carries the auditable decision chain. Use a preferred width around `460px`, with room to expand up to about `520px` on wide command workstations when the map camera reserves matching left padding. Do not widen it on medium or small screens; there it should become narrower or overlay on demand. + +The Agent panel is a persistent command instrument, so its outer shell should use the same control surface family as the map toolbar, zoom control, and scheduled-condition panel. It is not a low-opacity temporary glass panel. + +| Agent surface | Opacity | Use | +|---|---:|---| +| Outer shell | `rgba(255, 255, 255, 0.92)` | Main floating panel, collapsed rail, and mobile entry. Reuse the map control surface treatment: soft white border, compact control shadow, subtle dark ring, and backdrop blur. | +| Header, context strip, action tray, input band | `rgba(255, 255, 255, 0.96)` | Persistent readable bands that organize the command surface. | +| Conversation well | `rgba(255, 255, 255, 0.34)` | Low-density scroll area behind messages. Do not place text directly on this layer. | +| Message and recommendation surfaces | `rgba(255, 255, 255, 0.94)` | Assistant messages, recommendation groups, and primary readable content. | +| Nested evidence/tool result blocks | `rgba(248, 250, 252, 0.90)` | Evidence tiles, tool run rows, and compact structured outputs inside messages. | +| Prompt input and secondary controls | `rgba(255, 255, 255, 0.96)` | Text entry, secondary buttons, and controls that need crisp hit targets. | +| Active confirmation surface | `rgba(255, 247, 237, 0.96)` | Agent action review and confirmation state. | + +Do not return the Agent shell to a separate `0.78` panel treatment while the toolbar, zoom, and scheduled-condition panel use `surface-map-control`; those surfaces should read as one instrument family. Keep the conversation well lighter than the shell, and use readable local message surfaces for operational text. Do not use `0.58` or lower for any Agent region that contains operational text. + +Collapsed Agent state should be content-sized, not full height. It should keep only the expand button, Agent identity, and pending status indicators. Expanding and collapsing should use a short `150-180ms` reveal: opacity, `4-8px` horizontal translation, and very small scale change. Do not animate the map camera when the Agent panel changes state. + +Required sections: + +- Current task or operator prompt. +- Evidence summary. +- Step-by-step reasoning or plan. +- Recommended actions. +- Preview controls. +- Confirmation controls. +- Conversation or command input. + +Agent recommendation cards should show: + +- Action name. +- Target assets or area. +- Expected effect. +- Risk or confidence. +- Primary action: preview. +- Secondary action: inspect details. + +The Agent should use purple only for model identity or inference metadata. Primary operational action buttons should remain blue. + +### Scheduled Condition Panel + +The scheduled-condition panel is a persistent right-side command instrument for live operational tasks. It should visually belong to the same family as the toolbar, zoom control, and Agent shell, not to temporary tool panels. + +Use the same shell treatment as `surface-map-control`: + +- Outer shell: `rgba(255, 255, 255, 0.92)`, soft white border, subtle dark ring, compact control shadow, and `backdrop-blur-xl`. +- Outer radius: `rounded-2xl`, because the component reads as a major floating panel even when collapsed. +- Header, list containers, rows, and detail cards: `surface-readable` or `canvas-panel-strong`, around `0.94-0.96`. +- Icon cells: `rounded-lg`, matching toolbar icon cells. +- Rows and compact controls: `rounded-xl`. +- Detail content blocks: `rounded-xl` where the block carries multi-line text or structured details. + +Collapsed state: + +- Keep the compact width list-focused, with enough height to reveal roughly six recent rows before scrolling. +- Show recent conditions only. Do not show scheduled work orders in the collapsed state; they are secondary to the compact scanning task. +- The header should reflect the compact content, such as recent-condition count. Avoid advertising hidden scheduled-work content unless the expanded state is open. +- Use the same timeline row component and scroll container that the expanded state uses, so the operator does not lose list position when expanding. +- Row text should use compact 12px typography, with time on the left, status on the right, and a visible timeline marker for temporal scanning. + +Expanded state: + +- Add a separate detail region beside the timeline list. The detail region may use the newly available horizontal space for evidence, Agent analysis, recommended options, and operational metadata. +- Keep the timeline list column width and row typography close to the collapsed state. The list should feel like the same instrument after expansion. +- Show scheduled work orders as a separate compact group above recent conditions only in expanded state. If there are many work orders, keep that group scrollable and height-limited. +- Recent conditions sort newest to oldest. Today scheduled work sorts by start time. +- Detail panel height should be content-driven within viewport limits; avoid large empty areas. + +Details: + +- Historical condition details may include evidence, Agent recommendation, model/session metadata, risk, duration, and a continue-conversation action. +- Running condition details should be compact. The continue-conversation action is disabled until the task completes. +- Scheduled work orders are operational work details, not Agent sessions. Do not show model, session, Agent recommendation, or evidence chain for work orders. +- Work orders should show start time, estimated duration, computed end time, work content, execution mode, and due-time action. + +Behavior: + +- Hide the scheduled-condition panel when a right-side tool panel opens or when the operator performs a workspace action that would conflict spatially. +- Mobile v1 should not show this panel unless a dedicated compact pattern is designed. +- Show and hide the whole panel with a short instrument transition from the toolbar side: opacity, slight horizontal/vertical translation, and very small scale change at `180ms`. +- Collapse and expand the internal detail layout with a smooth width or grid-column transition around `180ms`. Reveal detail content only after the layout has settled. + +### Analysis Panel + +The analysis panel presents results of simulations, incidents, and selections. + +For incident impact, prioritize: + +- Affected households. +- Affected districts. +- Critical assets. +- Estimated recovery time. +- Valves or pipe segments involved. +- Confidence or data freshness. + +Metric cards should be compact and comparable. Use one large numeric value, one short label, and one optional trend/status indicator. + +### Layer And Tool Rail + +The tool rail should be icon-first and compact. Common tools stay visible; low-frequency tools go behind a menu. + +Recommended persistent tools: + +- Layers +- Measure +- Analysis +- Annotation +- Locate/search +- More + +Each icon button must have `aria-label` and a tooltip. Active state must be visually obvious through color and background, not only by icon color. + +### Map Core Controls + +Reusable controls under `features/map/core/components` should share one compact map-instrument language. + +#### Control Surface + +Zoom, toolbar, scaleline, and other always-visible map controls should use the `control` elevation level: + +- Surface: use `surface-map-control`, fixed at `rgba(255, 255, 255, 0.92)`. +- Border: white at roughly 70-80% opacity plus a very subtle dark ring when extra definition is needed. +- Shadow: soft multi-layer map shadow, not card-like depth. +- Backdrop blur: use lightly, only when text and icons remain readable. +- Radius: `rounded-xl` for grouped controls; `rounded-lg` for internal icon buttons. +- Do not stack a `command-panel` or second glass wrapper around toolbar, zoom, Agent shell, or scheduled-condition shell. + +Prefer Tailwind default opacity utilities for UI component surfaces, such as `bg-white/95`, `bg-blue-50/95`, and `shadow-lg shadow-slate-900/20`. Keep explicit color strings only for platform APIs such as MapLibre paint values, runtime dynamic `style` values, syntax-highlighting variables, or rare cases where an audited opacity cannot be represented clearly by Tailwind defaults. + +#### Zoom Control + +The zoom control should remain a small vertical instrument: + +- Width around `46px`. +- Icon buttons around `38px`. +- Buttons are flat and visually consistent. +- Do not show zoom readout unless a future workflow explicitly needs it. +- Use short centered horizontal separators between buttons. +- Button hover uses a soft blue background and subtle ring; do not add per-button lift shadows. +- The whole control, not each button, carries the elevation. + +#### Tool Rail + +The map toolbar should follow the same visual system as zoom: + +- Vertical rail width around `46px`; horizontal rail uses the same button size. +- Icon-only buttons, with `aria-label` and tooltip. +- Use short centered separators: horizontal dashes for vertical rails, vertical ticks for horizontal rails. +- Default buttons are flat; hover uses soft blue background and subtle ring. +- Active tools use blue fill with white icon/text and no heavy shadow. +- Badges may use red only for operational alerts or counts that require attention. +- The rail shell uses `surface-map-control` (`rgba(255, 255, 255, 0.92)`) and should not sit inside another translucent panel. + +Opening a tool rail panel should not move the map camera by default. Treat tool panels as floating overlays unless they become persistent major panels that materially obstruct map work. + +When a tool rail panel opens, closes, or changes active tool, animate the panel as a short instrument reveal or dismissal from the rail side. Use opacity, slight horizontal translation, and a very small scale change only; avoid large sliding drawers. + +#### Layer And Basemap Panel + +Layer visibility and basemap selection belong together in the Layers tool panel: + +- Business layer visibility appears first. +- Basemap selection appears below business layers. +- Toolbar function panels use `surface-tool-panel` (`rgba(255, 255, 255, 0.78)`) for the outer shell. +- Internal list rows, panel headers, swatches, result blocks, and action rows use `surface-readable`, around `0.94-0.96`, for readability. +- MVT sources should keep `bounds` where available to avoid invalid tile requests. This must not be confused with map view extent; the map view itself should remain unrestricted unless a workflow explicitly requires a view constraint. + +#### Scaleline + +The scaleline should read as a map instrument, not a dashboard footer: + +- It may retain technical metadata such as zoom, projection, coordinates, and attribution when useful. +- Width should be content-driven, not fixed. +- When positioned at the bottom-right edge, it may touch the viewport edge directly. +- If touching the bottom-right edge, only the top-left corner should be rounded. +- Use the same `surface-map-control` opacity as toolbar and zoom so the bottom-right instrument cluster reads as one family. +- Coordinate readout should match the displayed projection. If the scaleline labels `EPSG:3857`, prioritize visible projected `X/Y`; longitude and latitude may remain as secondary debug context, not the primary readout. +- Use only a short, smooth transition for the internal scale bar width. Keep metadata widths stable; do not animate the outer auto-sized container or coordinate text. + +### Simulation Timeline + +The timeline appears when simulation or replay mode is active. + +It should include: + +- Current simulation time. +- Play/pause. +- Previous/next step. +- Speed. +- Scenario selector. +- Comparison summary. +- Export/report action. + +The timeline should read as a control deck over the map, not as a separate dashboard section. + +### Feature Popover + +Clicking or hovering a pipe, junction, valve, or district should open a compact spatial popover. + +It should include: + +- Object type. +- Object name or id. +- Key status. +- 3-5 most important attributes. +- Related actions such as locate, inspect, isolate, add to scenario, or ask Agent. + +Do not open a large side panel for every selection unless the operator explicitly asks for details. + +## Map Layer Semantics + +### Pipes + +Pipe color represents category or operational state. Width represents diameter or importance. + +| Pipe State | Color | Guidance | +|---|---:|---| +| Major pipe | `network-major` | Strong blue, highest visual weight | +| Medium pipe | `network-medium` | Cyan/teal | +| Minor pipe | `network-minor` | Green | +| Closed pipe | `network-closed` | Muted gray | +| Hover/selected | Dark ink overlay or blue halo | Must remain legible over all basemaps | +| Risk preview | `risk` glow | Temporary, tied to preview state | + +### Junctions + +Junctions should remain smaller than pipes unless selected or critical. Use halo and stroke to preserve visibility. + +Demand intensity may use a green to blue to red ramp: + +- Low: `demand-low` +- Medium: `demand-medium` +- High: `demand-high` + +### Incidents And Impact + +Incidents require the strongest visual hierarchy. + +- Burst point: red point with white or pale red halo. +- Impact area: red fill at low opacity, red dashed or solid outline. +- Critical label: red text or red halo only where necessary. +- Warning preview: orange glow, not red, until confirmed as an incident. + +Red should be reserved for confirmed critical state. Overuse of red will reduce emergency legibility. + +### Labels + +Map labels must remain readable on both basemap and fallback grid. + +Use: + +- White halo for dark text. +- Blue halo for white pipe labels. +- 12-14px text. +- Overlap only for critical incident labels. + +## State Modes + +The workspace should have clear visual modes. + +### Normal Mode + +Purpose: monitor and inspect. + +- Light map canvas. +- Network colors visible but calm. +- Panels minimized or compact. +- No persistent warning animation. + +### Alert Mode + +Purpose: respond to active anomaly or incident. + +- Top bar alert indicator becomes prominent. +- Related asset and area are emphasized. +- Agent panel may open with incident context. +- Red appears only around confirmed critical objects. + +### Simulation Mode + +Purpose: compare future or hypothetical outcomes. + +- Bottom timeline appears. +- Scenario chips become visible. +- Simulation layers and impact overlays are enabled. +- The user can scrub, pause, compare, and export. + +### Agent Preview Mode + +Purpose: inspect an action before confirmation. + +- Proposed affected assets are highlighted. +- Expected impact is shown as temporary overlay. +- Confirmation controls become visible. +- Existing operational state remains distinguishable from preview state. + +### Confirmation Mode + +Purpose: prevent accidental operational change. + +- Use a focused confirmation surface. +- Summarize action, target, impact, risk, and rollback availability. +- Require explicit confirmation for destructive or irreversible actions. + +## Component Guidelines + +### Buttons + +Primary buttons: + +- Blue fill. +- White text. +- 40-44px height for important actions. +- Used for confirm, preview, run simulation, export report. + +Secondary buttons: + +- White or translucent fill. +- Blue or slate text. +- Border or subtle ring. +- Used for inspect, cancel, locate, details. + +Danger buttons: + +- Red only for destructive or emergency actions. +- Must include confirmation for operational changes. + +Icon buttons: + +- Prefer lucide icons. +- Minimum 32px target; 40px for important map tools. +- Always include accessible label and tooltip. + +### Cards + +Cards are for repeated data items, metrics, recommendations, and compact summaries. Do not use cards as generic page sections. + +Metric card structure: + +- Label. +- Value. +- Unit. +- Optional trend/status icon. + +Recommendation card structure: + +- Recommendation title. +- Target. +- Expected effect. +- Risk/confidence. +- Preview action. + +### Inputs + +Inputs should be compact and command-oriented. + +Use inputs for: + +- Agent prompt. +- Asset search. +- Scenario name. +- Numeric simulation parameters. + +Do not make the Agent input visually dominate the workspace. It is one command path, not the product itself. + +### Badges And Pills + +Use pills for: + +- Status. +- Severity. +- Scenario. +- Data freshness. +- Model version. + +Avoid pill-shaped decoration with no operational meaning. + +## Accessibility And Readability + +- Text on glass panels must pass practical contrast against busy map backgrounds. +- Increase panel opacity when content density increases. +- Do not rely on color alone; pair color with icon, label, shape, or position. +- Maintain visible focus states for keyboard use. +- Important controls should be reachable with predictable tab order. +- Use `aria-label` for icon-only controls. +- Avoid truncating critical identifiers without tooltip or detail access. + +## Responsive Behavior + +### Desktop Command Workspace + +Default target: desktop and large tablet. + +- Full map remains visible. +- Left Agent panel and right analysis/tool panel may be open simultaneously. +- Bottom timeline appears in simulation mode. +- Map camera respects open panel padding. + +### Medium Screens + +- Right analysis panel should collapse into tool rail. +- Agent panel may become narrower or overlay on demand. +- Timeline should reduce secondary controls behind menus. + +### Small Screens + +Small screens are supported for inspection and lightweight response, not full dispatch. + +- One major panel open at a time. +- Tool rail becomes bottom or side compact controls. +- Timeline controls simplify to play/pause, current time, and scenario. +- Avoid dense side-by-side metric grids. + +## Do's And Don'ts + +### Do + +- Make the map the first and strongest visual signal. +- Use floating panels as operational instruments. +- Preserve spatial context during Agent and simulation workflows. +- Use red only for confirmed critical state. +- Use orange for risk preview and warning state. +- Keep typography compact and readable. +- Use motion to explain spatial or temporal change. +- Give every Agent action a preview and confirmation path. + +### Don't + +- Do not build a landing page as the first screen. +- Do not use Apple-style product tiles, product photography, or oversized marketing typography. +- Do not use decorative gradients, glowing ornaments, or animated backgrounds as a substitute for information hierarchy. +- Do not let the Agent mutate state invisibly. +- Do not bury map controls inside generic cards. +- Do not use colors without operational meaning. +- Do not allow fixed panels to permanently cover critical map areas. +- Do not make the UI so transparent that text becomes hard to read. + +## Design Review Checklist + +Use this checklist for future UI changes: + +- The map is visible and useful in the first viewport. +- The main task can be understood within three seconds. +- Critical incident, warning, normal, and preview states are visually distinct. +- Open panels do not hide the target area without camera compensation. +- Agent recommendations include evidence, preview, confirmation, and rollback/audit path. +- Primary actions use blue unless they are destructive. +- Red is reserved for confirmed incident or destructive action. +- Text is readable over the map at desktop and mobile widths. +- Icon-only controls have labels and tooltips. +- Motion helps the user understand change. +- No component adds decorative chrome without operational value. + +## Implementation Guidance + +The codebase should eventually align to this document rather than letting current prototype components define the design language. + +Recommended implementation direction: + +- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`. +- Convert repeated glass surfaces, map controls, metric cards, status pills, and action cards into reusable typed components. +- Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components. +- Define operational colors as shared constants for both map layers and UI legends. +- Treat Agent actions as typed data that the workbench can preview, validate, confirm, apply, and roll back. + +### Component Library Strategy + +Use a headless or primitive component approach rather than adopting a fully styled application component library as the product's main UI system. + +Recommended stack: + +- Use Radix UI primitives for accessibility, keyboard behavior, focus management, portals, menus, dialogs, tooltips, selects, accordions, and other reusable interaction patterns. +- Use shadcn/ui-style copied components as a starting point when useful, but treat the copied source as project-owned code that must be restyled to this document. +- Use Tailwind CSS and shared design tokens for the visual layer. Prefer Tailwind default classes for radius, type scale, spacing, opacity, shadows, and semantic UI colors. Keep explicit color strings only where the platform API requires them, such as MapLibre paint values, runtime dynamic styles, syntax-highlighting variables, or rare audited opacity cases that Tailwind defaults cannot express clearly. +- Use `lucide-react` for iconography unless a domain-specific map symbol requires custom drawing. +- Use headless engines for complex behavior when appropriate, such as TanStack Table for sortable, filterable, selectable operational tables. + +Do not use MUI, Ant Design, Mantine, Chakra, or another fully styled component suite as the primary component library. Their default visual systems, spacing, radius, elevation, and layout assumptions conflict with the map-first command workspace and would require broad overrides. + +Target component layering: + +```txt +shared/ui + Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants. + +features/map/core/components + Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend. + +features/workbench/components + Water-network workbench components composed from shared UI and map core controls. + +features/agent/components + Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI. +``` + +The goal is to avoid repeating interaction and accessibility work while keeping full control over the visual language defined here. + +## Known Open Decisions + +- Dark mode is not specified in this version. A future night-operations mode should be designed deliberately rather than generated by inverting colors. +- Brand identity, logo, and exact Chinese product name are not finalized here. +- Mobile dispatch workflows need product decisions before becoming a full design spec. +- Real operational approval, audit, and rollback requirements should be validated with stakeholders before implementation. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b374b78 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Drainage Network Frontend + +排水管网 WebGIS 业务系统,基于 `next-webgis` 基础项目创建。 + +## 开发 + +```bash +pnpm install +pnpm dev +``` + +默认访问地址为 。 + +## 校验 + +```bash +pnpm lint +pnpm exec tsc --noEmit +pnpm build +``` + +地图服务和公开配置通过 `.env.local` 提供;可从 `.env.example` 创建本地配置,真实令牌不得提交。 diff --git a/app/api/v1/agent/chat/[...path]/route.ts b/app/api/v1/agent/chat/[...path]/route.ts new file mode 100644 index 0000000..f25cedb --- /dev/null +++ b/app/api/v1/agent/chat/[...path]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest } from "next/server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const AGENT_BACKEND_BASE_URL = + process.env.AGENT_API_INTERNAL_BASE_URL ?? + process.env.NEXT_PUBLIC_AGENT_API_BASE_URL ?? + "http://127.0.0.1:8787"; + +type RouteContext = { + params: Promise<{ path?: string[] }>; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + return proxyAgentRequest(request, context); +} + +export async function POST(request: NextRequest, context: RouteContext) { + return proxyAgentRequest(request, context); +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + return proxyAgentRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + return proxyAgentRequest(request, context); +} + +async function proxyAgentRequest(request: NextRequest, context: RouteContext) { + const { path = [] } = await context.params; + const upstreamUrl = new URL(`/api/v1/agent/chat/${path.map(encodeURIComponent).join("/")}`, AGENT_BACKEND_BASE_URL); + upstreamUrl.search = request.nextUrl.search; + + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("content-length"); + + const body = request.method === "GET" || request.method === "HEAD" ? undefined : request.body; + const upstreamResponse = await fetch(upstreamUrl, { + method: request.method, + headers, + body, + cache: "no-store", + duplex: body ? "half" : undefined + } as RequestInit & { duplex?: "half" }); + + const responseHeaders = new Headers(upstreamResponse.headers); + responseHeaders.delete("content-encoding"); + responseHeaders.delete("content-length"); + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: responseHeaders + }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b7d8162 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,487 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light; + background: #eaf1f8; + --canvas-map: #eaf1f8; + --canvas-map-grid: rgba(37, 99, 235, 0.06); + --canvas-panel: rgba(255, 255, 255, 0.76); + --canvas-panel-strong: rgba(255, 255, 255, 0.92); + --action-blue: #2563eb; + --action-blue-hover: #1d4ed8; + --action-blue-soft: #dbeafe; + --ink: #0f172a; + --ink-secondary: #334155; + --ink-muted: #64748b; + --border-soft: rgba(255, 255, 255, 0.62); + --border-strong: rgba(148, 163, 184, 0.34); + --shadow-control: 0 14px 34px rgba(15, 23, 42, 0.18); + --shadow-panel: 0 24px 70px rgba(30, 64, 115, 0.2); + --shadow-focus: 0 30px 90px rgba(15, 23, 42, 0.26); + --background: 210 40% 98%; + --foreground: 222 47% 11%; + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + --primary: 221 83% 53%; + --primary-foreground: 210 40% 98%; + --secondary: 214 32% 91%; + --secondary-foreground: 222 47% 11%; + --muted: 210 40% 96%; + --muted-foreground: 215 16% 47%; + --accent: 210 40% 96%; + --accent-foreground: 222 47% 11%; + --destructive: 0 84% 60%; + --destructive-foreground: 210 40% 98%; + --border: 214 32% 91%; + --input: 214 32% 91%; + --ring: 221 83% 53%; + --radius: 0.5rem; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; + color: var(--ink); +} + +button, +input { + font: inherit; +} + +button:focus-visible, +input:focus-visible { + outline: 2px solid #0f172a; + outline-offset: 2px; +} + +.map-grid { + background: + linear-gradient(rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.08)), + linear-gradient(90deg, var(--canvas-map-grid) 1px, transparent 1px), + linear-gradient(var(--canvas-map-grid) 1px, transparent 1px), + var(--canvas-map); + background-size: + auto, + 68px 68px, + 68px 68px, + auto; +} + +.command-panel { + border: 1px solid var(--border-soft); + background: var(--canvas-panel); + box-shadow: var(--shadow-panel); + backdrop-filter: blur(20px); +} + +.agent-panel-band { + background: rgba(255, 255, 255, 0.96); +} + +.agent-panel-conversation { + background: rgba(255, 255, 255, 0.34); +} + +.agent-panel-message { + border: 1px solid rgba(226, 232, 240, 0.72); + background: rgba(255, 255, 255, 0.94); +} + +.agent-streaming-response { + display: block; + --sd-animation: agent-stream-token-in; + --sd-duration: 260ms; + --sd-easing: cubic-bezier(0.16, 1, 0.3, 1); +} + +.agent-chart-line { + animation: agent-chart-line-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both; + transform-origin: center; + will-change: opacity, transform; +} + +.agent-chart-point, +.agent-chart-bar { + animation: agent-chart-mark-in 180ms cubic-bezier(0.22, 1, 0.36, 1) both; + transform-box: fill-box; + transform-origin: center bottom; + will-change: opacity, transform; +} + +.agent-panel-nested { + border: 1px solid rgba(203, 213, 225, 0.72); + background: rgba(248, 250, 252, 0.9); +} + +.agent-panel-control { + border: 1px solid rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.96); +} + +.agent-panel-icon-button { + border: 1px solid rgba(203, 213, 225, 0.72); + background: rgba(255, 255, 255, 0.92); + box-shadow: none; +} + +.agent-panel-icon-button:hover { + border-color: rgba(37, 99, 235, 0.26); + background: rgba(239, 246, 255, 0.94); + color: #1d4ed8; +} + +.agent-panel-primary-icon, +.agent-panel-primary-action { + border: 1px solid rgba(37, 99, 235, 0.24); + background: #2563eb; + color: #ffffff; + box-shadow: none; +} + +.agent-panel-primary-icon:hover, +.agent-panel-primary-action:hover { + border-color: rgba(29, 78, 216, 0.34); + background: #1d4ed8; + color: #ffffff; +} + +.agent-panel-primary-icon:disabled, +.agent-panel-primary-action:disabled { + background: #93c5fd; + color: rgba(255, 255, 255, 0.86); +} + +.scheduled-feed-scroll, +.scheduled-feed-detail-scroll, +.agent-conversation-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.26) transparent; + scrollbar-gutter: stable; + overscroll-behavior: contain; +} + +.scheduled-feed-scroll::-webkit-scrollbar, +.scheduled-feed-detail-scroll::-webkit-scrollbar, +.agent-conversation-scroll::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.scheduled-feed-scroll::-webkit-scrollbar-track, +.scheduled-feed-detail-scroll::-webkit-scrollbar-track, +.agent-conversation-scroll::-webkit-scrollbar-track { + background: transparent; + border-radius: 9999px; +} + +.scheduled-feed-scroll::-webkit-scrollbar-thumb, +.scheduled-feed-detail-scroll::-webkit-scrollbar-thumb, +.agent-conversation-scroll::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.24); + border-radius: 9999px; +} + +.scheduled-feed-scroll:hover::-webkit-scrollbar-thumb, +.scheduled-feed-detail-scroll:hover::-webkit-scrollbar-thumb, +.agent-conversation-scroll:hover::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.36); +} + +.scheduled-feed-panel-shell { + transition: + width 180ms cubic-bezier(0.22, 1, 0.36, 1), + max-height 180ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 150ms ease-out; +} + +.scheduled-feed-detail-enter { + animation: scheduled-feed-detail-show 140ms ease-out 180ms both; + will-change: opacity; +} + +.scheduled-feed-layout { + grid-template-columns: 0 minmax(0, 1fr); + column-gap: 0; + transition: + grid-template-columns 180ms cubic-bezier(0.22, 1, 0.36, 1), + column-gap 180ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: grid-template-columns; +} + +.scheduled-feed-layout-expanded { + grid-template-columns: minmax(0, 1fr) 432px; + column-gap: 0.75rem; +} + +.scheduled-feed-layout-collapsed { + grid-template-columns: 0 minmax(0, 1fr); + column-gap: 0; +} + +.scheduled-feed-shell-enter { + animation: + scheduled-feed-shell-show 170ms cubic-bezier(0.22, 1, 0.36, 1), + agent-panel-fade 140ms ease-out; + transform-origin: top right; + will-change: opacity, transform; +} + +.scheduled-feed-shell-exit { + animation: + scheduled-feed-shell-hide 170ms cubic-bezier(0.5, 0, 0.2, 1) forwards, + agent-panel-dim 140ms ease-in forwards; + pointer-events: none; + transform-origin: top right; + will-change: opacity, transform; +} + +.agent-panel-confirmation { + border: 1px solid rgba(254, 215, 170, 0.92); + background: rgba(255, 247, 237, 0.96); +} + +.agent-panel-enter { + animation: + agent-panel-reveal 170ms cubic-bezier(0.22, 1, 0.36, 1), + agent-panel-fade 150ms ease-out; + transform-origin: left top; + will-change: opacity, transform; +} + +.agent-panel-collapse { + animation: + agent-panel-dismiss 170ms cubic-bezier(0.5, 0, 0.2, 1) forwards, + agent-panel-dim 150ms ease-in forwards; + pointer-events: none; + transform-origin: left top; + will-change: opacity, transform; +} + +.agent-rail-enter { + animation: + agent-rail-shell 170ms cubic-bezier(0.22, 1, 0.36, 1), + agent-panel-fade 130ms ease-out; + transform-origin: left top; + will-change: opacity, transform; +} + +.agent-rail-item { + animation: agent-rail-item 180ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.agent-rail-item:nth-child(2) { + animation-delay: 24ms; +} + +.agent-rail-item:nth-child(3) { + animation-delay: 42ms; +} + +.agent-rail-item:nth-child(4) { + animation-delay: 58ms; +} + +@keyframes agent-panel-reveal { + from { + transform: translateX(-8px) scale(0.985); + } + + to { + transform: translateX(0) scale(1); + } +} + +@keyframes agent-panel-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes agent-panel-dim { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes agent-panel-dismiss { + from { + transform: translateX(0) scale(1); + } + + to { + transform: translateX(-8px) scale(0.985); + } +} + +@keyframes agent-rail-shell { + from { + transform: translateX(-5px) scale(0.94); + } + + to { + transform: translateX(0) scale(1); + } +} + +@keyframes agent-rail-item { + from { + opacity: 0; + transform: translateY(-3px) scale(0.92); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes agent-stream-token-in { + from { + opacity: 0; + filter: blur(0.75px); + transform: translateY(2px); + } + + 60% { + opacity: 0.88; + filter: blur(0); + } + + to { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } +} + +@keyframes agent-chart-line-in { + from { + opacity: 0; + transform: translateY(3px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes agent-chart-mark-in { + from { + opacity: 0; + transform: translateY(4px) scaleY(0.75); + } + + to { + opacity: 1; + transform: translateY(0) scaleY(1); + } +} + +@keyframes scheduled-feed-detail-show { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes scheduled-feed-shell-show { + from { + transform: translateX(10px) translateY(-4px) scale(0.985); + } + + to { + transform: translateX(0) translateY(0) scale(1); + } +} + +@keyframes scheduled-feed-shell-hide { + from { + transform: translateX(0) translateY(0) scale(1); + } + + to { + transform: translateX(10px) translateY(-4px) scale(0.985); + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-panel-enter, + .agent-panel-collapse, + .agent-rail-enter, + .agent-rail-item, + .scheduled-feed-shell-enter, + .scheduled-feed-shell-exit, + .scheduled-feed-detail-enter { + animation: none; + clip-path: none; + transform: none; + } + + .scheduled-feed-layout { + transition: none; + } + + .scheduled-feed-panel-shell { + transition: none; + } + + .agent-streaming-response [data-sd-animate] { + animation: none; + filter: none; + opacity: 1; + transform: none; + } + + .agent-chart-line, + .agent-chart-point, + .agent-chart-bar { + animation: none; + opacity: 1; + transform: none; + } +} + +.maplibregl-ctrl-group { + border: 1px solid rgba(255, 255, 255, 0.7) !important; + border-radius: 14px !important; + box-shadow: 0 14px 36px rgba(30, 64, 115, 0.14) !important; + overflow: hidden; +} + +.maplibregl-ctrl button { + width: 34px !important; + height: 34px !important; +} + +.maplibregl-ctrl-attrib { + border-radius: 8px 0 0 0 !important; + color: #475569 !important; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..1446548 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { MapToaster } from "@/features/map/core/components/notice"; +import "katex/dist/katex.min.css"; +import "streamdown/styles.css"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "排水管网", + description: "Agent 编排型沉浸式排水管网业务工作台" +}; + +export default function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..2339c07 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { MapWorkbenchPage } from "@/features/workbench"; + +export default function Home() { + return ; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..6fa8bbb --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/shared", + "ui": "@/shared/ui", + "lib": "@/lib", + "utils": "@/lib/utils", + "hooks": "@/shared/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..d0986a8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,41 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const nextConfigRequire = createRequire(require.resolve("eslint-config-next")); + +const nextPlugin = nextConfigRequire("@next/eslint-plugin-next"); +const reactHooksPlugin = nextConfigRequire("eslint-plugin-react-hooks"); +const tsParser = nextConfigRequire("@typescript-eslint/parser"); + +export default [ + { + ignores: [".next/**", "node_modules/**", "out/**"] + }, + { + files: ["**/*.{js,jsx,ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaFeatures: { + jsx: true + }, + ecmaVersion: "latest", + sourceType: "module" + } + }, + plugins: { + "@next/next": nextPlugin, + "react-hooks": reactHooksPlugin + }, + rules: { + ...nextPlugin.configs.recommended.rules, + ...nextPlugin.configs["core-web-vitals"].rules, + ...reactHooksPlugin.configs.recommended.rules + }, + settings: { + next: { + rootDir: ["./"] + } + } + } +]; diff --git a/features/agent/api/client.test.ts b/features/agent/api/client.test.ts new file mode 100644 index 0000000..f43b736 --- /dev/null +++ b/features/agent/api/client.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAgentApiClient } from "./client"; + +describe("Agent API client sessions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("lists normalized sessions in newest-first order", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + sessions: [ + { id: "old", title: "", created_at: 100, updated_at: 120 }, + { id: "new", title: " 新会话 ", created_at: 200, updated_at: 210, run_status: "running" } + ] + }), + { status: 200 } + ) + ) + ); + + const sessions = await createAgentApiClient("http://agent.local").listSessions(); + + expect(sessions).toEqual([ + { + id: "new", + title: "新会话", + createdAt: 200, + updatedAt: 210, + status: undefined, + parentSessionId: undefined, + isStreaming: undefined, + runStatus: "running" + }, + { + id: "old", + title: "新对话", + createdAt: 100, + updatedAt: 120, + status: undefined, + parentSessionId: undefined, + isStreaming: undefined, + runStatus: undefined + } + ]); + }); + + it("returns null when loading a missing session", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ message: "missing" }), { status: 404 }))); + + await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull(); + }); + + it("falls back when a candidate is not the agent service", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + schema_version: "agent-ui-registry@1", + chart_grammars: [], + components: [], + actions: [] + }), + { status: 200 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({ + schema_version: "agent-ui-registry@1", + chart_grammars: [], + components: [], + actions: [] + }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "http://not-agent.local/api/v1/agent/chat/ui-registry", + undefined + ); + expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined); + }); + + it("sends title update payloads using backend field names", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await createAgentApiClient("http://agent.local").updateSessionTitle("session-1", " 标题 ", { + isTitleManuallyEdited: true + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://agent.local/api/v1/agent/chat/session/session-1/title", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + title: "标题", + is_title_manually_edited: true + }) + }) + ); + }); + + it("streams session events from the backend SSE endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + [ + 'event: state\ndata: {"session_id":"session-1","messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"检查压力"}]}],"is_streaming":true,"run_status":"running"}\n\n', + 'event: token\ndata: {"session_id":"session-1","content":"处理中"}\n\n', + 'event: done\ndata: {"session_id":"session-1","duration_ms":20}\n\n' + ].join(""), + { status: 200 } + ) + ) + ); + const events: unknown[] = []; + + await createAgentApiClient("http://agent.local").streamSession("session-1", { + onEvent: (event) => events.push(event) + }); + + expect(events).toEqual([ + { + type: "state", + sessionId: "session-1", + messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "检查压力" }] }], + isStreaming: true, + runStatus: "running" + }, + { + type: "token", + sessionId: "session-1", + data: { session_id: "session-1", content: "处理中" } + }, + { + type: "done", + sessionId: "session-1", + data: { session_id: "session-1", duration_ms: 20 } + } + ]); + }); +}); diff --git a/features/agent/api/client.ts b/features/agent/api/client.ts new file mode 100644 index 0000000..8637c06 --- /dev/null +++ b/features/agent/api/client.ts @@ -0,0 +1,563 @@ +export type AgentRunStatus = "running" | "completed" | "error" | "aborted"; + +export type AgentPermissionReply = "once" | "always" | "reject"; + +export type AgentModelOption = { + id: string; + label: string; + description: string; + icon: "bolt" | "sparkle"; +}; + +export type AgentModelsResponse = { + defaultModel: string; + models: AgentModelOption[]; +}; + +export type AgentChatSession = { + id?: string; + session_id: string; + title?: string; + status?: string; + run_status?: AgentRunStatus; + is_streaming?: boolean; + messages?: unknown[]; + created_at?: number | string; + updated_at?: number | string; + is_title_manually_edited?: boolean; + parent_session_id?: string; +}; + +export type AgentChatSessionSummary = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + status?: string; + parentSessionId?: string; + isStreaming?: boolean; + runStatus?: AgentRunStatus; +}; + +export type AgentLoadedChatSession = AgentChatSessionSummary & { + sessionId: string; + isTitleManuallyEdited: boolean; + messages: unknown[]; +}; + +export type AgentSessionStreamDataEventType = + | "token" + | "progress" + | "todo_update" + | "permission_request" + | "permission_response" + | "question_request" + | "question_response" + | "tool_call" + | "ui_envelope" + | "session_title" + | "done" + | "error" + | "auth_required"; + +export type AgentSessionStreamEvent = + | { + type: "state"; + sessionId: string; + messages: unknown[]; + isStreaming: boolean; + runStatus?: AgentRunStatus; + } + | { + type: AgentSessionStreamDataEventType; + sessionId?: string; + data: Record; + }; + +export type AgentApiClient = { + createSession: () => Promise; + listSessions: () => Promise; + loadSession: (sessionId: string) => Promise; + streamSession: ( + sessionId: string, + options: { + signal?: AbortSignal; + onEvent: (event: AgentSessionStreamEvent) => void; + } + ) => Promise; + updateSessionTitle: ( + sessionId: string, + title: string, + options?: { isTitleManuallyEdited?: boolean } + ) => Promise; + deleteSession: (sessionId: string) => Promise; + getModels: () => Promise; + getUiRegistry: () => Promise; + resolveRenderRef: (renderRef: string, sessionId: string) => Promise; + replyPermission: ( + requestId: string, + options: { sessionId: string; reply: AgentPermissionReply; message?: string } + ) => Promise; + replyQuestion: ( + requestId: string, + options: { sessionId: string; answers: string[][] } + ) => Promise; + rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise; + abort: (sessionId: string) => Promise; +}; + +const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL + ? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")] + : ["", "http://127.0.0.1:8787"]; + +const CHAT_PATH = "/api/v1/agent/chat"; + +export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient { + const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, "")); + let activeBaseUrl = candidates[0] ?? ""; + + return { + async createSession() { + return requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, "/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + }); + }, + + async listSessions() { + const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>( + candidates, + activeBaseUrl, + (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, + "/sessions" + ); + return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries); + }, + + async loadSession(sessionId) { + const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, `/session/${encodeURIComponent(sessionId)}`); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(getResponseErrorMessage(data, response.status)); + } + + return toLoadedSession(data); + }, + + async streamSession(sessionId, options) { + const response = await fetchWithFallback( + candidates, + activeBaseUrl, + (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, + `/session/${encodeURIComponent(sessionId)}/stream`, + { signal: options.signal } + ); + + if (!response.ok) { + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + throw new Error(getResponseErrorMessage(data, response.status)); + } + + await readSessionSseStream(response, options.onEvent); + }, + + async updateSessionTitle(sessionId, title, options) { + const normalizedTitle = title.trim(); + if (!normalizedTitle) { + return; + } + + await requestJsonWithFallback( + candidates, + activeBaseUrl, + (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, + `/session/${encodeURIComponent(sessionId)}/title`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: normalizedTitle, + is_title_manually_edited: options?.isTitleManuallyEdited + }) + } + ); + }, + + async deleteSession(sessionId) { + await requestJsonWithFallback( + candidates, + activeBaseUrl, + (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, + `/session/${encodeURIComponent(sessionId)}`, + { method: "DELETE" } + ); + }, + + async getModels() { + const payload = await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, "/models"); + return toModelsResponse(payload); + }, + + async getUiRegistry() { + return requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, "/ui-registry"); + }, + + async resolveRenderRef(renderRef, sessionId) { + const params = new URLSearchParams({ session_id: sessionId }); + return requestJsonWithFallback( + candidates, + activeBaseUrl, + (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, + `/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}` + ); + }, + + async replyPermission(requestId, options) { + await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, `/permission/${encodeURIComponent(requestId)}/reply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: options.sessionId, + reply: options.reply, + message: options.message + }) + }); + }, + + async replyQuestion(requestId, options) { + await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, `/question/${encodeURIComponent(requestId)}/reply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: options.sessionId, + answers: options.answers + }) + }); + }, + + async rejectQuestion(requestId, options) { + await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, `/question/${encodeURIComponent(requestId)}/reject`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: options.sessionId + }) + }); + }, + + async abort(sessionId) { + await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { + activeBaseUrl = nextBaseUrl; + }, "/abort", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId }) + }); + } + }; +} + +async function requestJsonWithFallback( + baseUrls: string[], + activeBaseUrl: string, + setActiveBaseUrl: (baseUrl: string) => void, + path: string, + init?: RequestInit +) { + const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + throw new Error(getResponseErrorMessage(data, response.status)); + } + + return data as T; +} + +async function fetchWithFallback( + baseUrls: string[], + activeBaseUrl: string, + setActiveBaseUrl: (baseUrl: string) => void, + path: string, + init?: RequestInit +) { + const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; + let lastError: unknown; + let lastResponse: Response | null = null; + + for (const baseUrl of orderedBaseUrls) { + try { + const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init); + if (response.ok) { + setActiveBaseUrl(baseUrl); + return response; + } + + lastResponse = response; + if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) { + await response.body?.cancel(); + continue; + } + return response; + } catch (error) { + lastError = error; + } + } + + if (lastResponse) { + return lastResponse; + } + + throw lastError instanceof Error ? lastError : new Error("Agent API unavailable"); +} + +function shouldFallbackOnHttpStatus(status: number) { + return status === 404 || status === 405 || status === 502 || status === 503 || status === 504; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isPresent(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} + +function toModelsResponse(value: unknown): AgentModelsResponse { + if (!isRecord(value)) { + return { defaultModel: "", models: [] }; + } + + const models = Array.isArray(value.models) ? value.models.map(toModelOption).filter(isPresent) : []; + const defaultModel = typeof value.default_model === "string" ? value.default_model : models[0]?.id ?? ""; + return { + defaultModel, + models + }; +} + +function toModelOption(value: unknown): AgentModelOption | null { + if (!isRecord(value) || typeof value.id !== "string") { + return null; + } + + const label = typeof value.label === "string" && value.label.trim() ? value.label : value.id; + const description = + typeof value.description === "string" && value.description.trim() ? value.description : label; + const icon = value.icon === "bolt" || value.icon === "sparkle" ? value.icon : "sparkle"; + + return { + id: value.id, + label, + description, + icon + }; +} + +function getResponseErrorMessage(data: unknown, status: number) { + return isRecord(data) && typeof data.message === "string" + ? data.message + : `Agent API failed with HTTP ${status}`; +} + +function toMillis(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : Date.now(); + } + return Date.now(); +} + +function normalizeTitle(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : "新对话"; +} + +function readRunStatus(value: unknown): AgentRunStatus | undefined { + return value === "running" || value === "completed" || value === "error" || value === "aborted" + ? value + : undefined; +} + +function toSessionSummary(value: unknown): AgentChatSessionSummary | null { + if (!isRecord(value)) { + return null; + } + + const id = typeof value.id === "string" ? value.id : typeof value.session_id === "string" ? value.session_id : ""; + if (!id) { + return null; + } + + return { + id, + title: normalizeTitle(value.title), + createdAt: toMillis(value.created_at), + updatedAt: toMillis(value.updated_at), + status: typeof value.status === "string" ? value.status : undefined, + parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined, + isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined, + runStatus: readRunStatus(value.run_status) + }; +} + +function toLoadedSession(value: unknown): AgentLoadedChatSession { + const summary = toSessionSummary(value); + if (!summary || !isRecord(value)) { + throw new Error("Invalid agent session payload"); + } + + return { + ...summary, + sessionId: typeof value.session_id === "string" ? value.session_id : summary.id, + isTitleManuallyEdited: + typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false, + messages: Array.isArray(value.messages) ? value.messages : [] + }; +} + +async function readSessionSseStream( + response: Response, + onEvent: (event: AgentSessionStreamEvent) => void +) { + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + + let delimiterIndex = buffer.indexOf("\n\n"); + while (delimiterIndex !== -1) { + const rawEvent = buffer.slice(0, delimiterIndex); + buffer = buffer.slice(delimiterIndex + 2); + emitSessionSseEvent(rawEvent, onEvent); + delimiterIndex = buffer.indexOf("\n\n"); + } + + if (done) { + break; + } + } + + if (buffer.trim()) { + emitSessionSseEvent(buffer, onEvent); + } +} + +function emitSessionSseEvent( + rawEvent: string, + onEvent: (event: AgentSessionStreamEvent) => void +) { + const lines = rawEvent.split(/\r?\n/); + const eventType = lines + .find((line) => line.startsWith("event:")) + ?.slice("event:".length) + .trim(); + const dataText = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()) + .join("\n"); + + if (!eventType || !dataText) { + return; + } + + const data = JSON.parse(dataText) as unknown; + if (!isRecord(data)) { + return; + } + + if (eventType === "state") { + const sessionId = typeof data.session_id === "string" ? data.session_id : ""; + onEvent({ + type: "state", + sessionId, + messages: Array.isArray(data.messages) ? data.messages : [], + isStreaming: data.is_streaming === true, + runStatus: readRunStatus(data.run_status) + }); + return; + } + + if (!isSessionStreamDataEventType(eventType)) { + return; + } + + onEvent({ + type: eventType, + sessionId: typeof data.session_id === "string" ? data.session_id : undefined, + data + }); +} + +function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType { + return ( + value === "token" || + value === "progress" || + value === "todo_update" || + value === "permission_request" || + value === "permission_response" || + value === "question_request" || + value === "question_response" || + value === "tool_call" || + value === "ui_envelope" || + value === "session_title" || + value === "done" || + value === "error" || + value === "auth_required" + ); +} + +function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChatSessionSummary) { + const createdAtDiff = right.createdAt - left.createdAt; + if (createdAtDiff !== 0) { + return createdAtDiff; + } + + const updatedAtDiff = right.updatedAt - left.updatedAt; + if (updatedAtDiff !== 0) { + return updatedAtDiff; + } + + return right.id.localeCompare(left.id); +} diff --git a/features/agent/api/swr.test.ts b/features/agent/api/swr.test.ts new file mode 100644 index 0000000..404c98b --- /dev/null +++ b/features/agent/api/swr.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentApiClient } from "./client"; +import { fetchAgentBootstrap, fetchAgentSessions } from "./swr"; + +describe("Agent API SWR helpers", () => { + it("loads bootstrap data with a normalized UI registry", async () => { + const client = { + getUiRegistry: vi.fn(async () => ({ + schema_version: "agent-ui-registry@1", + chart_grammars: ["echarts-safe-subset"], + components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unknown"] }], + actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }] + })), + getModels: vi.fn(async () => ({ + defaultModel: "model-a", + models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }] + })) + } as Pick as AgentApiClient; + + await expect(fetchAgentBootstrap(client)).resolves.toEqual({ + defaultModel: "model-a", + models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }], + registry: { + schema_version: "agent-ui-registry@1", + chart_grammars: ["echarts-safe-subset"], + components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel"] }], + actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }] + } + }); + }); + + it("loads session summaries through the shared client", async () => { + const sessions = [ + { + id: "session-1", + title: "会话", + createdAt: 1, + updatedAt: 2 + } + ]; + const client = { + listSessions: vi.fn(async () => sessions) + } as Pick as AgentApiClient; + + await expect(fetchAgentSessions(client)).resolves.toBe(sessions); + }); +}); diff --git a/features/agent/api/swr.ts b/features/agent/api/swr.ts new file mode 100644 index 0000000..cf81bd1 --- /dev/null +++ b/features/agent/api/swr.ts @@ -0,0 +1,32 @@ +import type { + AgentApiClient, + AgentChatSessionSummary, + AgentModelOption +} from "./client"; +import { parseUiRegistry, type UIRegistry } from "../ui-envelope"; + +export const agentBootstrapKey = ["agent", "bootstrap"] as const; +export const agentSessionsKey = ["agent", "sessions"] as const; + +export type AgentBootstrapData = { + registry: UIRegistry | null; + models: AgentModelOption[]; + defaultModel: string; +}; + +export async function fetchAgentBootstrap(client: AgentApiClient): Promise { + const [rawRegistry, modelsResponse] = await Promise.all([ + client.getUiRegistry(), + client.getModels() + ]); + + return { + registry: parseUiRegistry(rawRegistry), + models: modelsResponse.models, + defaultModel: modelsResponse.defaultModel + }; +} + +export async function fetchAgentSessions(client: AgentApiClient): Promise { + return client.listSessions(); +} diff --git a/features/agent/chart-data.test.ts b/features/agent/chart-data.test.ts new file mode 100644 index 0000000..c908a6c --- /dev/null +++ b/features/agent/chart-data.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data"; + +describe("chart data normalization", () => { + it("parses chart spec aliases", () => { + expect(parseChartSpec({ title: "压力", chart_type: "bar", x_axis_name: "时间" })).toEqual({ + title: "压力", + chartType: "bar", + xAxisName: "时间", + yAxisName: undefined + }); + }); + + it("normalizes series with x_data and numeric strings", () => { + expect( + normalizeSafeChartData({ + x_data: ["00:00", "01:00"], + series: [{ name: "压力", data: ["1.2", 1.4] }] + }) + ).toEqual({ + xData: ["00:00", "01:00"], + series: [{ name: "压力", data: [1.2, 1.4], type: undefined }] + }); + }); + + it("normalizes raw point arrays into a default series", () => { + expect( + normalizeSafeChartData({ + series: [ + ["A", 12], + ["B", "13"] + ] + }) + ).toEqual({ + xData: ["A", "B"], + series: [{ name: "数据", data: [12, 13], type: undefined }] + }); + }); + + it("normalizes object points", () => { + expect(pointToLabelValue({ time: "10:00", value: "2.4" }, "fallback")).toEqual({ + label: "10:00", + value: 2.4 + }); + }); + + it("clips chart points to the safe limit", () => { + const data = normalizeSafeChartData( + { + x_data: Array.from({ length: 30 }, (_, index) => String(index)), + series: [{ name: "流量", data: Array.from({ length: 30 }, (_, index) => index) }] + }, + 5 + ); + + expect(data?.xData).toHaveLength(5); + expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]); + }); +}); diff --git a/features/agent/chart-data.ts b/features/agent/chart-data.ts new file mode 100644 index 0000000..6ac3b6a --- /dev/null +++ b/features/agent/chart-data.ts @@ -0,0 +1,183 @@ +export type SafeChartSpec = { + title?: string; + chartType: "line" | "bar"; + xAxisName?: string; + yAxisName?: string; +}; + +export type SafeChartSeries = { + name: string; + data: number[]; + type?: "line" | "bar"; +}; + +export type SafeChartData = { + xData: string[]; + series: SafeChartSeries[]; +}; + +type RawChartPoint = number | string | [unknown, unknown] | RawChartPointObject; + +type RawChartPointObject = { + x?: unknown; + y?: unknown; + time?: unknown; + timestamp?: unknown; + label?: unknown; + name?: unknown; + value?: unknown; +}; + +type RawChartSeries = { + name?: unknown; + data?: unknown; + points?: unknown; + values?: unknown; + type?: unknown; +}; + +export function parseChartSpec(value: unknown): SafeChartSpec { + if (!isRecord(value)) { + return { chartType: "line" }; + } + + return { + title: stringValue(value.title), + chartType: value.chart_type === "bar" || value.chartType === "bar" ? "bar" : "line", + xAxisName: stringValue(value.x_axis_name ?? value.xAxisName), + yAxisName: stringValue(value.y_axis_name ?? value.yAxisName) + }; +} + +export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChartData | null { + if (!isRecord(value)) { + return null; + } + + const xData = normalizeXData(value.x_data ?? value.xData); + const rawSeriesItems = normalizeRawSeriesItems(value.series); + if (!rawSeriesItems.length) { + return null; + } + + const normalizedSeries = rawSeriesItems + .map((rawItem, seriesIndex): SafeChartSeries | null => { + const item = + isRecord(rawItem) && !isRawChartPoint(rawItem) ? rawItem : ({ data: rawItem } satisfies RawChartSeries); + const rawData = item.data ?? item.points ?? item.values; + if (!Array.isArray(rawData)) { + return null; + } + + const labelsFromPoints: string[] = []; + const data = rawData + .slice(0, maxPoints) + .map((point, index) => { + const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`); + if (!parsed) { + return null; + } + labelsFromPoints[index] = parsed.label; + return parsed.value; + }) + .filter((item): item is number => item !== null); + + if (!data.length) { + return null; + } + + if (!xData.length && labelsFromPoints.length) { + xData.push(...labelsFromPoints); + } + + return { + name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`, + data, + type: item.type === "line" || item.type === "bar" ? item.type : undefined + }; + }) + .filter((item): item is SafeChartSeries => Boolean(item)); + + const clippedXData = xData.slice(0, maxPoints); + const clippedSeries = normalizedSeries.map((series) => ({ + ...series, + data: series.data.slice(0, clippedXData.length) + })); + + return clippedXData.length > 0 && clippedSeries.length > 0 + ? { + xData: clippedXData, + series: clippedSeries + } + : null; +} + +export function pointToLabelValue(point: RawChartPoint, fallbackLabel: string): { label: string; value: number } | null { + const directValue = toFiniteNumber(point); + if (directValue !== null) { + return { label: fallbackLabel, value: directValue }; + } + + if (Array.isArray(point)) { + const value = toFiniteNumber(point[1]); + if (value === null) { + return null; + } + return { label: String(point[0] ?? fallbackLabel), value }; + } + + if (isRecord(point)) { + const value = toFiniteNumber(point.value ?? point.y); + if (value === null) { + return null; + } + const label = point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel; + return { label: String(label), value }; + } + + return null; +} + +function normalizeXData(rawXData: unknown): string[] { + return Array.isArray(rawXData) ? rawXData.map((item) => String(item ?? "")).filter(Boolean) : []; +} + +function normalizeRawSeriesItems(rawSeries: unknown): unknown[] { + if (!Array.isArray(rawSeries)) { + return isRecord(rawSeries) ? [rawSeries] : []; + } + + return rawSeries.length > 0 && rawSeries.every(isRawChartPoint) ? [{ name: "数据", data: rawSeries }] : rawSeries; +} + +function isRawChartPoint(item: unknown): boolean { + if (toFiniteNumber(item) !== null) { + return true; + } + if (Array.isArray(item)) { + return item.length >= 2 && toFiniteNumber(item[1]) !== null; + } + if (isRecord(item)) { + return item.data === undefined && item.points === undefined && item.values === undefined && toFiniteNumber(item.value ?? item.y) !== null; + } + return false; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function stringValue(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/features/agent/components/agent-collapsed-rail.tsx b/features/agent/components/agent-collapsed-rail.tsx new file mode 100644 index 0000000..2d77737 --- /dev/null +++ b/features/agent/components/agent-collapsed-rail.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import type { PersonaState } from "@/shared/ai-elements/persona"; +import { cn } from "@/lib/utils"; +import { + MAP_CONTROL_SURFACE_CLASS_NAME, + MAP_MAJOR_PANEL_RADIUS_CLASS_NAME +} from "@/features/map/core/components/map-control-styles"; +import { AgentPersona } from "./agent-persona"; + +type AgentCollapsedRailProps = { + personaState?: PersonaState; + statusLabel?: string; + onExpand: () => void; +}; + +export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) { + const state = getCollapsedAgentState(statusLabel, personaState); + + return ( + + ); +} + +function getCollapsedAgentState(statusLabel: string, personaState?: PersonaState) { + if (personaState === "asleep" || /失败|不可用|错误|降级/.test(statusLabel)) { + return { + label: "离线", + dotClassName: "bg-red-500" + }; + } + + if (personaState === "listening") { + return { + label: "待确认", + dotClassName: "bg-orange-500" + }; + } + + if (personaState === "thinking") { + return { + label: "处理中", + dotClassName: "bg-blue-500" + }; + } + + return { + label: "在线", + dotClassName: "bg-emerald-500" + }; +} diff --git a/features/agent/components/agent-command-panel.tsx b/features/agent/components/agent-command-panel.tsx new file mode 100644 index 0000000..98009e3 --- /dev/null +++ b/features/agent/components/agent-command-panel.tsx @@ -0,0 +1,629 @@ +"use client"; + +import { + Bolt, + CheckCircle2, + ChevronsUpDown, + ChevronLeft, + Gauge, + History, + Plus, + SendHorizontal, + Sparkles +} from "lucide-react"; +import { AnimatePresence, MotionConfig, motion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useStickToBottomContext } from "use-stick-to-bottom"; +import { Agent, AgentContent } from "@/shared/ai-elements/agent"; +import { + Conversation, + ConversationContent, + ConversationScrollButton +} from "@/shared/ai-elements/conversation"; +import type { PersonaState } from "@/shared/ai-elements/persona"; +import { Message, MessageContent } from "@/shared/ai-elements/message"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea +} from "@/shared/ai-elements/prompt-input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from "@/shared/ui/dropdown-menu"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/lib/utils"; +import { + MAP_CONTROL_SURFACE_CLASS_NAME, + MAP_MAJOR_PANEL_RADIUS_CLASS_NAME +} from "@/features/map/core/components/map-control-styles"; +import type { AgentChatSessionSummary } from "../api/client"; +import { AgentPersona } from "./agent-persona"; +import { + agentLayoutTransition, + agentPanelItemVariants, + agentPanelListVariants, + agentPanelSectionVariants +} from "./agent-motion"; +import { AgentHistoryPanel } from "./agent-history-panel"; +import { AgentMessageDetails } from "./agent-message-details"; +import { AgentOperationalBrief } from "./agent-operational-brief"; +import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer"; +import { StreamingTokenResponse } from "./streaming-token-response"; +import type { + AgentChatMessage, + AgentModelOption, + AgentPermissionReply, + AgentPermissionRequest, + AgentQuestionRequest, + AgentStreamRenderState, + AgentUiResult +} from "../types"; + +type AgentCommandPanelProps = { + collapsing?: boolean; + personaState?: PersonaState; + sessionTitle?: string; + sessionHistory?: AgentChatSessionSummary[]; + sessionHistoryLoading?: boolean; + activeSessionId?: string | null; + statusLabel?: string; + streaming?: boolean; + messages?: AgentChatMessage[]; + streamRenderState?: AgentStreamRenderState; + modelOptions?: AgentModelOption[]; + selectedModel?: string; + uiResults?: AgentUiResult[]; + onCollapse: () => void; + onRefreshHistory?: () => Promise | void; + onStartNewSession?: () => Promise | void; + onLoadHistorySession?: (sessionId: string) => Promise | void; + onRenameHistorySession?: (sessionId: string, title: string) => Promise | void; + onDeleteHistorySession?: (sessionId: string) => Promise | void; + onSelectModel?: (model: string) => void; + onSubmitPrompt: (prompt: string) => Promise | void; + onStopPrompt?: () => void; + onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise | void; + onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void; + onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void; +}; + +export function AgentCommandPanel({ + collapsing = false, + personaState, + sessionTitle = "新对话", + sessionHistory = [], + sessionHistoryLoading = false, + activeSessionId, + statusLabel = "Agent 后端连接中", + streaming = false, + messages = [], + streamRenderState = {}, + modelOptions = [], + selectedModel = "", + uiResults = [], + onCollapse, + onRefreshHistory, + onStartNewSession, + onLoadHistorySession, + onRenameHistorySession, + onDeleteHistorySession, + onSelectModel, + onSubmitPrompt, + onStopPrompt, + onReplyPermission, + onReplyQuestion, + onRejectQuestion +}: AgentCommandPanelProps) { + const [prompt, setPrompt] = useState(""); + const [historyOpen, setHistoryOpen] = useState(false); + const [loadingSessionId, setLoadingSessionId] = useState(null); + const [scrollRequestId, setScrollRequestId] = useState(0); + const trimmedPrompt = prompt.trim(); + + useEffect(() => { + if (historyOpen) { + void Promise.resolve(onRefreshHistory?.()); + } + }, [historyOpen, onRefreshHistory]); + + const submitPrompt = (nextPrompt: string) => { + if (!nextPrompt || streaming) { + return; + } + setPrompt(""); + setScrollRequestId((requestId) => requestId + 1); + void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => { + setPrompt(nextPrompt); + }); + }; + + return ( + + + + ); +} + +type AgentConversationScrollSnapshot = { + activeSessionId?: string | null; + lastUserMessageId: string | null; + scrollRequestId: number; + signature: string; +}; + +function AgentConversationScrollManager({ + activeSessionId, + messages, + scrollRequestId, + streaming, + uiResults +}: { + activeSessionId?: string | null; + messages: AgentChatMessage[]; + scrollRequestId: number; + streaming: boolean; + uiResults: AgentUiResult[]; +}) { + const { escapedFromLock, isAtBottom, scrollToBottom, state } = useStickToBottomContext(); + const previousSnapshotRef = useRef(null); + const wasPinnedToBottom = !escapedFromLock && (isAtBottom || state.isAtBottom || state.isNearBottom); + const snapshot = useMemo( + () => ({ + activeSessionId, + lastUserMessageId: getLastUserMessageId(messages), + scrollRequestId, + signature: createAgentConversationScrollSignature(messages, streaming, uiResults) + }), + [activeSessionId, messages, scrollRequestId, streaming, uiResults] + ); + + useEffect(() => { + const previousSnapshot = previousSnapshotRef.current; + previousSnapshotRef.current = snapshot; + + const forceScroll = + !previousSnapshot || + previousSnapshot.activeSessionId !== snapshot.activeSessionId || + previousSnapshot.scrollRequestId !== snapshot.scrollRequestId || + (Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId); + const contentChanged = !previousSnapshot || previousSnapshot.signature !== snapshot.signature; + + if (!forceScroll && (!contentChanged || !wasPinnedToBottom)) { + return; + } + + const animationFrameId = window.requestAnimationFrame(() => { + void scrollToBottom({ + animation: "instant", + ignoreEscapes: forceScroll, + wait: !forceScroll + }); + }); + + return () => window.cancelAnimationFrame(animationFrameId); + }, [scrollToBottom, snapshot, wasPinnedToBottom]); + + return null; +} + +function getLastUserMessageId(messages: AgentChatMessage[]) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].role === "user") { + return messages[index].id; + } + } + + return null; +} + +function createAgentConversationScrollSignature( + messages: AgentChatMessage[], + streaming: boolean, + uiResults: AgentUiResult[] +) { + return [ + streaming ? "streaming" : "idle", + messages.map(createAgentMessageScrollSignature).join("|"), + uiResults.map((result) => result.id).join("|") + ].join("#"); +} + +function createAgentMessageScrollSignature(message: AgentChatMessage) { + return [ + message.id, + message.role, + message.content.length, + message.progress?.map((item) => `${item.id}:${item.status}:${item.title.length}:${item.detail?.length ?? 0}`).join(",") ?? "", + message.todos?.todos.map((todo) => `${todo.id}:${todo.status}:${todo.content.length}`).join(",") ?? "", + message.permissions?.map((permission) => `${permission.requestId}:${permission.status}:${permission.error?.length ?? 0}`).join(",") ?? "", + message.questions?.map((question) => `${question.requestId}:${question.status}:${question.error?.length ?? 0}`).join(",") ?? "" + ].join(":"); +} + +function AgentModelSelect({ + models, + value, + onValueChange +}: { + models: AgentModelOption[]; + value: string; + onValueChange?: (model: string) => void; +}) { + const selectedModel = models.find((model) => model.id === value) ?? models[0]; + + if (!models.length) { + return ( + + 模型 + + ); + } + + return ( + + + + + + + Agent 模型 + +
+ {models.map((model) => { + const selected = model.id === selectedModel?.id; + return ( + onValueChange?.(model.id)} + > + + + + + {model.label} + {model.description} + + {selected ? + ); + })} +
+
+
+ ); +} + +function getAgentConnectionLabel(statusLabel: string) { + return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线"; +} + +function getAgentConnectionClassName(statusLabel: string) { + return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500"; +} + +function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) { + return model?.icon === "bolt" ? ( +