CallMeDaniel Claude Opus 4.6 (1M context) commited on
Commit
d46cdb3
·
1 Parent(s): edf10d0

feat: add DesignState memory + localStorage persistence

Browse files

- agents/design_state.py: Pydantic DesignState model with regex-based
extraction of materials, dimensions, fasteners, axis recommendations,
and design decisions from agent messages (no extra LLM call)
- agents/prompts.py: inject "Current Design Spec" block into LLM
context so decisions survive history truncation
- agents/orchestrator.py: both orchestrators maintain and return
design_state across turns
- server/routes.py: pass design_state through API
- web/index.html: localStorage persistence for chat history and
design state, restore on page load, "NEW" button to start fresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

agents/crew_orchestrator.py CHANGED
@@ -10,10 +10,7 @@ Note: Falls back to SingleCallOrchestrator if CrewAI is not installed.
10
  from __future__ import annotations
11
 
12
  from pathlib import Path
13
- from typing import Optional
14
 
15
- from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
16
- from agents.prompts import route_by_keywords
17
 
18
 
19
  DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
@@ -46,13 +43,14 @@ class CrewOrchestrator:
46
  history: list[dict],
47
  mentions: list[str] | None = None,
48
  max_history: int = 30,
 
49
  ) -> dict:
50
  """Run one chat turn using CrewAI multi-call process.
51
 
52
  Falls back to SingleCallOrchestrator if CrewAI is not available.
53
 
54
  Returns same format as SingleCallOrchestrator:
55
- {"responses": [...], "preview": None | {...}}
56
  """
57
  if not self._crew_available:
58
  # Fallback to single-call
@@ -67,12 +65,12 @@ class CrewOrchestrator:
67
  except Exception:
68
  from agents.orchestrator import MockChatBackend
69
  mock = MockChatBackend()
70
- return mock.chat_turn(message, history, mentions)
71
 
72
  orchestrator = SingleCallOrchestrator(
73
  backend=backend, output_dir=self.output_dir
74
  )
75
- return orchestrator.chat_turn(message, history, mentions, max_history)
76
 
77
  # TODO: Implement CrewAI hierarchical process
78
  # For now, delegate to single-call as well
@@ -87,9 +85,9 @@ class CrewOrchestrator:
87
  except Exception:
88
  from agents.orchestrator import MockChatBackend
89
  mock = MockChatBackend()
90
- return mock.chat_turn(message, history, mentions)
91
 
92
  orchestrator = SingleCallOrchestrator(
93
  backend=backend, output_dir=self.output_dir
94
  )
95
- return orchestrator.chat_turn(message, history, mentions, max_history)
 
10
  from __future__ import annotations
11
 
12
  from pathlib import Path
 
13
 
 
 
14
 
15
 
16
  DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
 
43
  history: list[dict],
44
  mentions: list[str] | None = None,
45
  max_history: int = 30,
46
+ design_state: dict | None = None,
47
  ) -> dict:
48
  """Run one chat turn using CrewAI multi-call process.
49
 
50
  Falls back to SingleCallOrchestrator if CrewAI is not available.
51
 
52
  Returns same format as SingleCallOrchestrator:
53
+ {"responses": [...], "preview": None | {...}, "design_state": {...}}
54
  """
55
  if not self._crew_available:
56
  # Fallback to single-call
 
65
  except Exception:
66
  from agents.orchestrator import MockChatBackend
67
  mock = MockChatBackend()
68
+ return mock.chat_turn(message, history, mentions, design_state=design_state)
69
 
70
  orchestrator = SingleCallOrchestrator(
71
  backend=backend, output_dir=self.output_dir
72
  )
73
+ return orchestrator.chat_turn(message, history, mentions, max_history, design_state=design_state)
74
 
75
  # TODO: Implement CrewAI hierarchical process
76
  # For now, delegate to single-call as well
 
85
  except Exception:
86
  from agents.orchestrator import MockChatBackend
87
  mock = MockChatBackend()
88
+ return mock.chat_turn(message, history, mentions, design_state=design_state)
89
 
90
  orchestrator = SingleCallOrchestrator(
91
  backend=backend, output_dir=self.output_dir
92
  )
93
+ return orchestrator.chat_turn(message, history, mentions, max_history, design_state=design_state)
agents/design_state.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Design state accumulator — extracts and persists key decisions from agent messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class DesignState(BaseModel):
10
+ """Structured state tracking design decisions across chat turns."""
11
+ part_name: str = ""
12
+ description: str = ""
13
+ material: str = ""
14
+ dimensions: dict[str, float] = Field(default_factory=dict)
15
+ features: list[str] = Field(default_factory=list)
16
+ constraints: list[str] = Field(default_factory=list)
17
+ decisions: list[str] = Field(default_factory=list)
18
+ axis_recommendation: str = ""
19
+
20
+ def render(self) -> str:
21
+ """Render non-empty fields as a concise spec block for LLM context."""
22
+ lines = []
23
+ if self.part_name:
24
+ lines.append(f"Part: {self.part_name}")
25
+ if self.description:
26
+ lines.append(f"Description: {self.description}")
27
+ if self.material:
28
+ lines.append(f"Material: {self.material}")
29
+ if self.dimensions:
30
+ dims = ", ".join(f"{k}={v}mm" for k, v in self.dimensions.items())
31
+ lines.append(f"Dimensions: {dims}")
32
+ if self.features:
33
+ lines.append(f"Features: {'; '.join(self.features)}")
34
+ if self.constraints:
35
+ lines.append(f"Constraints: {'; '.join(self.constraints)}")
36
+ if self.axis_recommendation:
37
+ lines.append(f"Axis: {self.axis_recommendation}")
38
+ if self.decisions:
39
+ lines.append("Decisions:")
40
+ for d in self.decisions[-5:]: # Last 5 decisions to keep it concise
41
+ lines.append(f" - {d}")
42
+ return "\n".join(lines) if lines else ""
43
+
44
+
45
+ # ── Material patterns ──────────────────────────────────────────────────────
46
+
47
+ _MATERIALS = [
48
+ "aluminum", "aluminium", "steel", "stainless steel", "brass", "copper",
49
+ "titanium", "nylon", "delrin", "acetal", "abs", "polycarbonate", "peek",
50
+ ]
51
+ _MATERIAL_GRADES = {
52
+ "6061": "aluminum 6061", "7075": "aluminum 7075",
53
+ "304": "stainless steel 304", "316": "stainless steel 316",
54
+ "t6": "aluminum 6061-T6",
55
+ }
56
+
57
+ # ── Dimension context words ────────────────────────────────────────────────
58
+
59
+ _DIM_CONTEXTS = {
60
+ "wide": "width", "width": "width",
61
+ "tall": "height", "height": "height", "high": "height",
62
+ "thick": "thickness", "thickness": "thickness",
63
+ "deep": "depth", "depth": "depth",
64
+ "long": "length", "length": "length",
65
+ "diameter": "diameter", "dia": "diameter",
66
+ "radius": "radius",
67
+ "arm": "arm_length",
68
+ }
69
+
70
+
71
+ def extract_decisions(
72
+ agent_responses: list[dict],
73
+ current_state: DesignState,
74
+ user_message: str = "",
75
+ ) -> DesignState:
76
+ """Extract design decisions from agent responses and update state.
77
+
78
+ Uses regex/keyword matching — no extra LLM call.
79
+ """
80
+ state = current_state.model_copy(deep=True)
81
+
82
+ # Combine all text for scanning
83
+ all_text = user_message + " " + " ".join(r.get("message", "") for r in agent_responses)
84
+ lower = all_text.lower()
85
+
86
+ # Extract material
87
+ for grade, full_name in _MATERIAL_GRADES.items():
88
+ if grade in lower:
89
+ state.material = full_name
90
+ break
91
+ else:
92
+ for mat in _MATERIALS:
93
+ if mat in lower:
94
+ state.material = mat
95
+ break
96
+
97
+ # Extract dimensions: "60mm wide", "width of 60mm", "60 mm thick"
98
+ dim_pattern = re.compile(
99
+ r'(\d+\.?\d*)\s*mm\s+(' + '|'.join(_DIM_CONTEXTS.keys()) + r')',
100
+ re.IGNORECASE,
101
+ )
102
+ for match in dim_pattern.finditer(all_text):
103
+ value = float(match.group(1))
104
+ word = match.group(2).lower()
105
+ dim_name = _DIM_CONTEXTS.get(word, word)
106
+ state.dimensions[dim_name] = value
107
+
108
+ # Also match "width: 60mm" or "width of 60mm" patterns
109
+ dim_pattern2 = re.compile(
110
+ r'(' + '|'.join(_DIM_CONTEXTS.keys()) + r')\s*(?:of|:|\s)\s*(\d+\.?\d*)\s*mm',
111
+ re.IGNORECASE,
112
+ )
113
+ for match in dim_pattern2.finditer(all_text):
114
+ word = match.group(1).lower()
115
+ value = float(match.group(2))
116
+ dim_name = _DIM_CONTEXTS.get(word, word)
117
+ state.dimensions[dim_name] = value
118
+
119
+ # Extract fastener features: "4x M6 holes", "M4 clearance holes"
120
+ fastener_pattern = re.compile(r'(\d+)\s*[x\u00d7]\s*(M\d+)\s+\w*\s*hole', re.IGNORECASE)
121
+ for match in fastener_pattern.finditer(all_text):
122
+ feature = f"{match.group(1)}x {match.group(2).upper()} holes"
123
+ if feature not in state.features:
124
+ state.features.append(feature)
125
+
126
+ # Single fastener mention: "M6 holes", "M3 clearance holes"
127
+ single_fastener = re.compile(r'(M\d+)\s+(?:clearance\s+)?(?:hole|bolt|screw)', re.IGNORECASE)
128
+ for match in single_fastener.finditer(all_text):
129
+ feature = f"{match.group(1).upper()} holes"
130
+ if feature not in state.features and not any(feature.split()[0] in f for f in state.features):
131
+ state.features.append(feature)
132
+
133
+ # Extract axis recommendation
134
+ axis_pattern = re.compile(r'(3-axis|3\+2[\s-]*axis|5-axis)', re.IGNORECASE)
135
+ axis_match = axis_pattern.search(all_text)
136
+ if axis_match:
137
+ state.axis_recommendation = axis_match.group(1).lower()
138
+
139
+ # Extract constraint keywords
140
+ constraint_patterns = [
141
+ (r'min(?:imum)?\s+wall\s+(?:thickness\s+)?(\d+\.?\d*)\s*mm', "min wall {}mm"),
142
+ (r'max(?:imum)?\s+(?:part\s+)?size\s+(\d+\.?\d*)\s*mm', "max size {}mm"),
143
+ ]
144
+ for pattern, template in constraint_patterns:
145
+ match = re.search(pattern, all_text, re.IGNORECASE)
146
+ if match:
147
+ constraint = template.format(match.group(1))
148
+ if constraint not in state.constraints:
149
+ state.constraints.append(constraint)
150
+
151
+ # Extract decisions: sentences with agreement language from agent messages only
152
+ for resp in agent_responses:
153
+ msg = resp.get("message", "")
154
+ sentences = re.split(r'[.!?]+', msg)
155
+ for sentence in sentences:
156
+ s = sentence.strip()
157
+ if len(s) > 15 and any(kw in s.lower() for kw in [
158
+ "recommend", "suggest", "should use", "let's go with",
159
+ "i'd use", "best to", "we'll need", "i'll specify",
160
+ ]):
161
+ if s not in state.decisions and len(state.decisions) < 20:
162
+ state.decisions.append(s)
163
+
164
+ # Extract part name from user message if not set
165
+ if not state.part_name and user_message:
166
+ name_patterns = [
167
+ r'(?:need|want|design|make|create)\s+(?:a|an)\s+(.{5,40?})\s*(?:with|for|that|,|$)',
168
+ ]
169
+ for pattern in name_patterns:
170
+ match = re.search(pattern, user_message, re.IGNORECASE)
171
+ if match:
172
+ state.part_name = match.group(1).strip()
173
+ break
174
+
175
+ return state
agents/orchestrator.py CHANGED
@@ -25,6 +25,7 @@ from agents.prompts import (
25
  parse_orchestrator_response,
26
  CAD_TRIGGER_KEYWORDS,
27
  )
 
28
  from core.backends import LLMBackend, MockBackend
29
  from core.executor import execute_cadquery, export_all
30
  from core.validator import validate_for_cnc
@@ -154,8 +155,10 @@ class MockChatBackend:
154
  history: list[dict],
155
  mentions: list[str] | None = None,
156
  max_history: int = 30,
 
157
  ) -> dict:
158
- """Return ``{"responses": [...], "preview": ...}``."""
 
159
  lower = message.lower()
160
 
161
  # Determine which agents respond
@@ -197,7 +200,10 @@ class MockChatBackend:
197
  )
198
  preview = _execute_cad_code(code, message, self.output_dir)
199
 
200
- return {"responses": responses, "preview": preview}
 
 
 
201
 
202
  # -- canned response templates -------------------------------------------
203
 
@@ -283,6 +289,7 @@ class SingleCallOrchestrator:
283
  history: list[dict],
284
  mentions: list[str] | None = None,
285
  max_history: int = 30,
 
286
  ) -> dict:
287
  """Run one chat turn: user message -> agent responses.
288
 
@@ -291,10 +298,13 @@ class SingleCallOrchestrator:
291
  history: Previous messages [{role, agent_id, content}, ...].
292
  mentions: Agent IDs explicitly mentioned by user. ``None`` = auto-route.
293
  max_history: Max history messages to include in context.
 
294
 
295
  Returns:
296
- ``{"responses": [...], "preview": None | {...}}``
297
  """
 
 
298
  # Determine which agents are active
299
  active_agents = mentions if mentions else None # None lets orchestrator decide
300
 
@@ -315,6 +325,7 @@ class SingleCallOrchestrator:
315
  history=history,
316
  system_prompt=system_prompt,
317
  max_history=max_history,
 
318
  )
319
 
320
  # Single LLM call
@@ -350,7 +361,10 @@ class SingleCallOrchestrator:
350
  resp["code"], message, self.output_dir, backend=self.backend,
351
  )
352
 
353
- return {"responses": formatted, "preview": preview}
 
 
 
354
 
355
 
356
  # ---------------------------------------------------------------------------
 
25
  parse_orchestrator_response,
26
  CAD_TRIGGER_KEYWORDS,
27
  )
28
+ from agents.design_state import DesignState, extract_decisions
29
  from core.backends import LLMBackend, MockBackend
30
  from core.executor import execute_cadquery, export_all
31
  from core.validator import validate_for_cnc
 
155
  history: list[dict],
156
  mentions: list[str] | None = None,
157
  max_history: int = 30,
158
+ design_state: dict | None = None,
159
  ) -> dict:
160
+ """Return ``{"responses": [...], "preview": ..., "design_state": ...}``."""
161
+ state = DesignState(**(design_state or {}))
162
  lower = message.lower()
163
 
164
  # Determine which agents respond
 
200
  )
201
  preview = _execute_cad_code(code, message, self.output_dir)
202
 
203
+ # Update design state from responses
204
+ updated_state = extract_decisions(responses, state, message)
205
+
206
+ return {"responses": responses, "preview": preview, "design_state": updated_state.model_dump()}
207
 
208
  # -- canned response templates -------------------------------------------
209
 
 
289
  history: list[dict],
290
  mentions: list[str] | None = None,
291
  max_history: int = 30,
292
+ design_state: dict | None = None,
293
  ) -> dict:
294
  """Run one chat turn: user message -> agent responses.
295
 
 
298
  history: Previous messages [{role, agent_id, content}, ...].
299
  mentions: Agent IDs explicitly mentioned by user. ``None`` = auto-route.
300
  max_history: Max history messages to include in context.
301
+ design_state: Persisted design state dict from previous turns.
302
 
303
  Returns:
304
+ ``{"responses": [...], "preview": None | {...}, "design_state": {...}}``
305
  """
306
+ state = DesignState(**(design_state or {}))
307
+
308
  # Determine which agents are active
309
  active_agents = mentions if mentions else None # None lets orchestrator decide
310
 
 
325
  history=history,
326
  system_prompt=system_prompt,
327
  max_history=max_history,
328
+ design_state_text=state.render(),
329
  )
330
 
331
  # Single LLM call
 
361
  resp["code"], message, self.output_dir, backend=self.backend,
362
  )
363
 
364
+ # Update design state from responses
365
+ updated_state = extract_decisions(formatted, state, message)
366
+
367
+ return {"responses": formatted, "preview": preview, "design_state": updated_state.model_dump()}
368
 
369
 
370
  # ---------------------------------------------------------------------------
agents/prompts.py CHANGED
@@ -99,6 +99,7 @@ def build_chat_messages(
99
  history: list[dict],
100
  system_prompt: str,
101
  max_history: int = 30,
 
102
  ) -> list[dict]:
103
  """Build the message list for the orchestrator LLM call.
104
 
@@ -107,6 +108,7 @@ def build_chat_messages(
107
  history: Previous messages [{role, agent_id, content}, ...].
108
  system_prompt: The orchestrator system prompt.
109
  max_history: Maximum number of history messages to include.
 
110
  """
111
  messages = [{"role": "system", "content": system_prompt}]
112
 
@@ -115,6 +117,9 @@ def build_chat_messages(
115
 
116
  # Bundle history into a single context block to avoid Gemini
117
  # treating prior agent messages as its own output and repeating them.
 
 
 
118
  if recent:
119
  history_lines = []
120
  for msg in recent:
@@ -126,14 +131,10 @@ def build_chat_messages(
126
  history_lines.append(f"{agent_name.upper()}: {msg['content']}")
127
 
128
  history_block = "\n".join(history_lines)
129
- messages.append({
130
- "role": "user",
131
- "content": f"## Conversation so far:\n{history_block}\n\n"
132
- f"## User's new message:\n{user_message}\n\n"
133
- "Respond to the user's NEW message above. Do NOT repeat prior responses."
134
- })
135
- else:
136
- messages.append({"role": "user", "content": user_message})
137
 
138
  return messages
139
 
 
99
  history: list[dict],
100
  system_prompt: str,
101
  max_history: int = 30,
102
+ design_state_text: str = "",
103
  ) -> list[dict]:
104
  """Build the message list for the orchestrator LLM call.
105
 
 
108
  history: Previous messages [{role, agent_id, content}, ...].
109
  system_prompt: The orchestrator system prompt.
110
  max_history: Maximum number of history messages to include.
111
+ design_state_text: Rendered design state spec to inject as context.
112
  """
113
  messages = [{"role": "system", "content": system_prompt}]
114
 
 
117
 
118
  # Bundle history into a single context block to avoid Gemini
119
  # treating prior agent messages as its own output and repeating them.
120
+ content_parts = []
121
+ if design_state_text:
122
+ content_parts.append(f"## Current Design Spec (agreed so far)\n{design_state_text}\n")
123
  if recent:
124
  history_lines = []
125
  for msg in recent:
 
131
  history_lines.append(f"{agent_name.upper()}: {msg['content']}")
132
 
133
  history_block = "\n".join(history_lines)
134
+ content_parts.append(f"## Conversation so far:\n{history_block}\n")
135
+ content_parts.append(f"## User's new message:\n{user_message}\n\nRespond to the user's NEW message above. Do NOT repeat prior responses.")
136
+
137
+ messages.append({"role": "user", "content": "\n".join(content_parts)})
 
 
 
 
138
 
139
  return messages
140
 
server/routes.py CHANGED
@@ -33,6 +33,7 @@ class ChatRequest(BaseModel):
33
  history: list[ChatMessage] = Field(default_factory=list)
34
  mentions: list[str] = Field(default_factory=list)
35
  backend: str = "mock"
 
36
 
37
 
38
  class ReportRequest(BaseModel):
@@ -74,6 +75,7 @@ async def chat(body: ChatRequest):
74
  message=message,
75
  history=history,
76
  mentions=mentions,
 
77
  )
78
  return JSONResponse(result)
79
  except Exception as e:
 
33
  history: list[ChatMessage] = Field(default_factory=list)
34
  mentions: list[str] = Field(default_factory=list)
35
  backend: str = "mock"
36
+ design_state: dict = Field(default_factory=dict)
37
 
38
 
39
  class ReportRequest(BaseModel):
 
75
  message=message,
76
  history=history,
77
  mentions=mentions,
78
+ design_state=body.design_state,
79
  )
80
  return JSONResponse(result)
81
  except Exception as e:
web/index.html CHANGED
@@ -1143,6 +1143,7 @@
1143
  <div class="chat-header">
1144
  <div class="chat-header-left">
1145
  <span class="chat-header-title">Design Chat</span>
 
1146
  <div class="agent-dots">
1147
  <div class="agent-dot" style="background: var(--agent-design);" title="Design Agent"></div>
1148
  <div class="agent-dot" style="background: var(--agent-engineering);" title="Engineering Agent"></div>
@@ -1244,6 +1245,7 @@
1244
 
1245
  let currentBackend = 'mock';
1246
  let chatHistory = [];
 
1247
  let chatPanelOpen = true;
1248
  let currentPartName = '';
1249
  let currentCode = '';
@@ -1252,6 +1254,58 @@ const galleryItems = [];
1252
  let mentionActive = false;
1253
  let mentionIndex = 0;
1254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1255
  const AGENTS = {
1256
  design: { name: 'Design', color: '#7c3aed', avatar: 'D' },
1257
  engineering: { name: 'Engineering', color: '#00b4d8', avatar: 'E' },
@@ -1440,11 +1494,13 @@ async function sendMessage(text) {
1440
  history: chatHistory,
1441
  mentions: mentions,
1442
  backend: currentBackend,
 
1443
  }),
1444
  });
1445
 
1446
  // Add to history AFTER sending (so it's included in future turns)
1447
  chatHistory.push({ role: 'user', content: text });
 
1448
  const data = await resp.json();
1449
 
1450
  hideTyping();
@@ -1463,6 +1519,11 @@ async function sendMessage(text) {
1463
  chatHistory.push({ role: 'agent', agent_id: r.agent_id, content: r.message });
1464
  }
1465
 
 
 
 
 
 
1466
  // If preview available, load 3D model
1467
  if (data.preview && data.preview.success) {
1468
  setViewerLoading(true, 'LOADING 3D MODEL...');
@@ -1907,6 +1968,16 @@ document.addEventListener('keydown', (e) => {
1907
  initViewer();
1908
  checkServer();
1909
  setInterval(checkServer, 15000);
 
 
 
 
 
 
 
 
 
 
1910
  </script>
1911
  </body>
1912
  </html>
 
1143
  <div class="chat-header">
1144
  <div class="chat-header-left">
1145
  <span class="chat-header-title">Design Chat</span>
1146
+ <button onclick="newDesign()" title="New Design" style="background:none;border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);padding:2px 8px;font-size:10px;cursor:pointer;margin-left:8px;">NEW</button>
1147
  <div class="agent-dots">
1148
  <div class="agent-dot" style="background: var(--agent-design);" title="Design Agent"></div>
1149
  <div class="agent-dot" style="background: var(--agent-engineering);" title="Engineering Agent"></div>
 
1245
 
1246
  let currentBackend = 'mock';
1247
  let chatHistory = [];
1248
+ let designState = {};
1249
  let chatPanelOpen = true;
1250
  let currentPartName = '';
1251
  let currentCode = '';
 
1254
  let mentionActive = false;
1255
  let mentionIndex = 0;
1256
 
1257
+ // Persist/restore from localStorage
1258
+ function saveState() {
1259
+ try {
1260
+ localStorage.setItem('neuralcad_history', JSON.stringify(chatHistory));
1261
+ localStorage.setItem('neuralcad_state', JSON.stringify(designState));
1262
+ } catch (e) { /* quota exceeded, ignore */ }
1263
+ }
1264
+
1265
+ function loadState() {
1266
+ try {
1267
+ const h = localStorage.getItem('neuralcad_history');
1268
+ const s = localStorage.getItem('neuralcad_state');
1269
+ if (h) chatHistory = JSON.parse(h);
1270
+ if (s) designState = JSON.parse(s);
1271
+ } catch (e) { /* corrupted, ignore */ }
1272
+ }
1273
+
1274
+ function clearState() {
1275
+ chatHistory = [];
1276
+ designState = {};
1277
+ localStorage.removeItem('neuralcad_history');
1278
+ localStorage.removeItem('neuralcad_state');
1279
+ }
1280
+
1281
+ function newDesign() {
1282
+ if (!confirm('Start a new design? Current conversation will be cleared.')) return;
1283
+ clearState();
1284
+ // Clear chat UI
1285
+ const msgs = document.getElementById('chat-messages');
1286
+ if (msgs) msgs.innerHTML = '';
1287
+ // Show examples again
1288
+ const examples = document.getElementById('quick-examples');
1289
+ if (examples) examples.style.display = '';
1290
+ // Clear 3D viewer
1291
+ if (currentMesh) {
1292
+ scene.remove(currentMesh);
1293
+ currentMesh.geometry.dispose();
1294
+ currentMesh.material.dispose();
1295
+ currentMesh = null;
1296
+ }
1297
+ // Hide overlays
1298
+ const geo = document.getElementById('geo-stats');
1299
+ if (geo) geo.classList.remove('visible');
1300
+ const cnc = document.getElementById('cnc-badge');
1301
+ if (cnc) cnc.classList.remove('visible');
1302
+ const dl = document.getElementById('download-btns');
1303
+ if (dl) dl.classList.remove('visible');
1304
+ // Show empty state
1305
+ const empty = document.getElementById('viewer-empty');
1306
+ if (empty) empty.style.display = '';
1307
+ }
1308
+
1309
  const AGENTS = {
1310
  design: { name: 'Design', color: '#7c3aed', avatar: 'D' },
1311
  engineering: { name: 'Engineering', color: '#00b4d8', avatar: 'E' },
 
1494
  history: chatHistory,
1495
  mentions: mentions,
1496
  backend: currentBackend,
1497
+ design_state: designState,
1498
  }),
1499
  });
1500
 
1501
  // Add to history AFTER sending (so it's included in future turns)
1502
  chatHistory.push({ role: 'user', content: text });
1503
+ saveState();
1504
  const data = await resp.json();
1505
 
1506
  hideTyping();
 
1519
  chatHistory.push({ role: 'agent', agent_id: r.agent_id, content: r.message });
1520
  }
1521
 
1522
+ if (data.design_state) {
1523
+ designState = data.design_state;
1524
+ }
1525
+ saveState();
1526
+
1527
  // If preview available, load 3D model
1528
  if (data.preview && data.preview.success) {
1529
  setViewerLoading(true, 'LOADING 3D MODEL...');
 
1968
  initViewer();
1969
  checkServer();
1970
  setInterval(checkServer, 15000);
1971
+
1972
+ loadState();
1973
+ // Re-render restored messages
1974
+ if (chatHistory.length > 0) {
1975
+ const examples = document.getElementById('quick-examples');
1976
+ if (examples) examples.style.display = 'none';
1977
+ for (const msg of chatHistory) {
1978
+ addMessage(msg);
1979
+ }
1980
+ }
1981
  </script>
1982
  </body>
1983
  </html>