jostlebot Claude Sonnet 5 commited on
Commit
e6f8bce
·
1 Parent(s): b15f737

Improve perceived and actual response latency

Browse files

- Reuse a single httpx.AsyncClient across requests (FastAPI lifespan)
instead of opening a fresh connection to api.anthropic.com on every
message, avoiding a repeated TCP+TLS handshake per turn.
- Stream Claude's reply to the browser as plain text (Anthropic SSE
parsed server-side) instead of waiting for the full completion, so
ARI tool replies and partner messages render incrementally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +74 -56
  2. static/index.html +121 -96
app.py CHANGED
@@ -8,15 +8,31 @@ ARI never becomes the relationship - it augments human-to-human connection.
8
  Created by Jocelyn Skillman, LMHC
9
  """
10
 
 
11
  import os
 
 
12
  import httpx
13
  from fastapi import FastAPI, HTTPException
14
  from fastapi.staticfiles import StaticFiles
15
- from fastapi.responses import FileResponse
16
  from pydantic import BaseModel
17
  from typing import List, Optional
18
 
19
- app = FastAPI(title="Tend & Send ARI Prototype")
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  # Get API key from environment (set as HF Space secret)
22
  # Check multiple possible secret names for flexibility
@@ -504,13 +520,51 @@ class ChatRequest(BaseModel):
504
  max_tokens: int = 500
505
 
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  # ============================================================================
508
  # API ENDPOINTS
509
  # ============================================================================
510
 
511
  @app.post("/api/tool")
512
  async def use_tool(request: ToolRequest):
513
- """Process an ARI tool request"""
514
  if not ANTHROPIC_API_KEY:
515
  raise HTTPException(status_code=500, detail="API key not configured")
516
 
@@ -547,66 +601,30 @@ MINIMAL MODE (user wants just the essentials):
547
  user_message += f"Current stage: {request.stage}\n\n"
548
  user_message += request.user_input
549
 
550
- async with httpx.AsyncClient() as client:
551
- try:
552
- response = await client.post(
553
- "https://api.anthropic.com/v1/messages",
554
- headers={
555
- "Content-Type": "application/json",
556
- "x-api-key": ANTHROPIC_API_KEY,
557
- "anthropic-version": "2023-06-01"
558
- },
559
- json={
560
- "model": "claude-sonnet-4-20250514",
561
- "max_tokens": 1000,
562
- "temperature": 0.4,
563
- "system": system_prompt,
564
- "messages": [{"role": "user", "content": user_message}]
565
- },
566
- timeout=60.0
567
- )
568
- response.raise_for_status()
569
- result = response.json()
570
- return {
571
- "tool": request.tool,
572
- "response": result["content"][0]["text"]
573
- }
574
- except httpx.HTTPStatusError as e:
575
- raise HTTPException(status_code=e.response.status_code, detail=str(e))
576
- except Exception as e:
577
- raise HTTPException(status_code=500, detail=str(e))
578
 
579
 
580
  @app.post("/api/chat")
581
  async def chat(request: ChatRequest):
582
- """General chat endpoint for partner simulation"""
583
  if not ANTHROPIC_API_KEY:
584
  raise HTTPException(status_code=500, detail="API key not configured")
585
 
586
- async with httpx.AsyncClient() as client:
587
- try:
588
- response = await client.post(
589
- "https://api.anthropic.com/v1/messages",
590
- headers={
591
- "Content-Type": "application/json",
592
- "x-api-key": ANTHROPIC_API_KEY,
593
- "anthropic-version": "2023-06-01"
594
- },
595
- json={
596
- "model": "claude-sonnet-4-20250514",
597
- "max_tokens": request.max_tokens,
598
- "temperature": 0.5,
599
- "system": request.system,
600
- "messages": [{"role": m.role, "content": m.content} for m in request.messages]
601
- },
602
- timeout=60.0
603
- )
604
- response.raise_for_status()
605
- return response.json()
606
- except httpx.HTTPStatusError as e:
607
- raise HTTPException(status_code=e.response.status_code, detail=str(e))
608
- except Exception as e:
609
- raise HTTPException(status_code=500, detail=str(e))
610
 
611
 
612
  @app.get("/api/health")
 
8
  Created by Jocelyn Skillman, LMHC
9
  """
10
 
11
+ import json
12
  import os
13
+ from contextlib import asynccontextmanager
14
+
15
  import httpx
16
  from fastapi import FastAPI, HTTPException
17
  from fastapi.staticfiles import StaticFiles
18
+ from fastapi.responses import FileResponse, StreamingResponse
19
  from pydantic import BaseModel
20
  from typing import List, Optional
21
 
22
+ # Reused across requests so calls to api.anthropic.com share a warm
23
+ # connection pool instead of paying a fresh TCP+TLS handshake every turn.
24
+ http_client: httpx.AsyncClient | None = None
25
+
26
+
27
+ @asynccontextmanager
28
+ async def lifespan(app: FastAPI):
29
+ global http_client
30
+ http_client = httpx.AsyncClient(timeout=60.0)
31
+ yield
32
+ await http_client.aclose()
33
+
34
+
35
+ app = FastAPI(title="Tend & Send ARI Prototype", lifespan=lifespan)
36
 
37
  # Get API key from environment (set as HF Space secret)
38
  # Check multiple possible secret names for flexibility
 
520
  max_tokens: int = 500
521
 
522
 
523
+ # ============================================================================
524
+ # STREAMING HELPER
525
+ # ============================================================================
526
+
527
+ async def stream_anthropic_text(payload: dict):
528
+ """POST to the Anthropic Messages API with streaming enabled and yield
529
+ plain-text chunks as they arrive, so the browser can render the reply
530
+ incrementally instead of waiting for the full completion."""
531
+ payload = {**payload, "stream": True}
532
+ async with http_client.stream(
533
+ "POST",
534
+ "https://api.anthropic.com/v1/messages",
535
+ headers={
536
+ "Content-Type": "application/json",
537
+ "x-api-key": ANTHROPIC_API_KEY,
538
+ "anthropic-version": "2023-06-01"
539
+ },
540
+ json=payload
541
+ ) as response:
542
+ if response.status_code >= 400:
543
+ body = await response.aread()
544
+ raise HTTPException(status_code=response.status_code, detail=body.decode(errors="replace"))
545
+
546
+ async for line in response.aiter_lines():
547
+ if not line.startswith("data: "):
548
+ continue
549
+ data = line[len("data: "):]
550
+ try:
551
+ event = json.loads(data)
552
+ except json.JSONDecodeError:
553
+ continue
554
+ if event.get("type") == "content_block_delta":
555
+ delta = event.get("delta", {})
556
+ text = delta.get("text")
557
+ if text:
558
+ yield text
559
+
560
+
561
  # ============================================================================
562
  # API ENDPOINTS
563
  # ============================================================================
564
 
565
  @app.post("/api/tool")
566
  async def use_tool(request: ToolRequest):
567
+ """Process an ARI tool request, streaming the reply as plain text."""
568
  if not ANTHROPIC_API_KEY:
569
  raise HTTPException(status_code=500, detail="API key not configured")
570
 
 
601
  user_message += f"Current stage: {request.stage}\n\n"
602
  user_message += request.user_input
603
 
604
+ payload = {
605
+ "model": "claude-sonnet-4-20250514",
606
+ "max_tokens": 1000,
607
+ "temperature": 0.4,
608
+ "system": system_prompt,
609
+ "messages": [{"role": "user", "content": user_message}]
610
+ }
611
+ return StreamingResponse(stream_anthropic_text(payload), media_type="text/plain; charset=utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
 
613
 
614
  @app.post("/api/chat")
615
  async def chat(request: ChatRequest):
616
+ """General chat endpoint for partner simulation, streamed as plain text."""
617
  if not ANTHROPIC_API_KEY:
618
  raise HTTPException(status_code=500, detail="API key not configured")
619
 
620
+ payload = {
621
+ "model": "claude-sonnet-4-20250514",
622
+ "max_tokens": request.max_tokens,
623
+ "temperature": 0.5,
624
+ "system": request.system,
625
+ "messages": [{"role": m.role, "content": m.content} for m in request.messages]
626
+ }
627
+ return StreamingResponse(stream_anthropic_text(payload), media_type="text/plain; charset=utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
 
629
 
630
  @app.get("/api/health")
static/index.html CHANGED
@@ -1126,6 +1126,35 @@
1126
  let lastPartnerMessage = '';
1127
  let conversationStarted = false;
1128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1129
  const TOOL_MAP = {
1130
  tend: { name: 'TEND Transform', icon: '✨', api: 'tend' },
1131
  soma: { name: 'Somatic Check-in', icon: '🫁', api: 'somatic_checkin' },
@@ -1573,30 +1602,30 @@ Real grief, real limits. 1-2 sentences.`
1573
  addToolLoading();
1574
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
1575
 
 
1576
  try {
1577
- const response = await fetch('/api/tool', {
1578
- method: 'POST',
1579
- headers: { 'Content-Type': 'application/json' },
1580
- body: JSON.stringify({
1581
- tool: apiTool,
1582
- partner_message: lastPartnerMessage,
1583
- user_draft: userInput,
1584
- user_input: inputText,
1585
- verbose: isVerbose
1586
- })
 
 
1587
  });
1588
 
1589
- const data = await response.json();
1590
- removeToolLoading();
1591
-
1592
- if (data.response) {
1593
- addToolMsg('tool', data.response, TOOL_MAP[tool]?.name);
1594
- } else {
1595
  addToolMsg('tool', 'Could not process. Check API.', 'Error');
1596
  }
1597
  } catch (error) {
1598
  removeToolLoading();
1599
- addToolMsg('tool', 'Connection error.', 'Error');
 
1600
  }
1601
  }
1602
 
@@ -1611,32 +1640,31 @@ Real grief, real limits. 1-2 sentences.`
1611
 
1612
  addToolLoading();
1613
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
 
1614
  try {
1615
- const response = await fetch('/api/tool', {
1616
- method: 'POST',
1617
- headers: { 'Content-Type': 'application/json' },
1618
- body: JSON.stringify({
1619
- tool: 'feelings_needs',
1620
- partner_message: lastPartnerMessage,
1621
- user_draft: '',
1622
- user_input: `The person identified these feelings: ${feelingsText}. ${userInput ? 'They added: ' + userInput : ''}
1623
 
1624
  Be BRIEF (3-5 sentences max). No headers, no bullet points, no explicit citations.
1625
 
1626
  Validate the feeling. Share one insight about what this feeling tends to signal in relationships or the nervous system. Name what need might be underneath. Keep it warm and conversational - building emotional intelligence, not lecturing.
1627
 
1628
  Draw wisdom from attachment neuroscience, somatic awareness, and relational psychology - but embed it naturally, don't name sources.`,
1629
- verbose: isVerbose
1630
- })
 
 
 
 
 
1631
  });
1632
- const data = await response.json();
1633
- removeToolLoading();
1634
- if (data.response) {
1635
- addToolMsg('tool', data.response, 'Feelings');
1636
- }
1637
  } catch (e) {
1638
  removeToolLoading();
1639
- addToolMsg('tool', 'Connection error.', 'Error');
 
1640
  }
1641
  input.value = '';
1642
  return;
@@ -1649,32 +1677,31 @@ Draw wisdom from attachment neuroscience, somatic awareness, and relational psyc
1649
 
1650
  addToolLoading();
1651
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
 
1652
  try {
1653
- const response = await fetch('/api/tool', {
1654
- method: 'POST',
1655
- headers: { 'Content-Type': 'application/json' },
1656
- body: JSON.stringify({
1657
- tool: 'feelings_needs',
1658
- partner_message: lastPartnerMessage,
1659
- user_draft: '',
1660
- user_input: `The person identified these needs: ${needsText}. ${userInput ? 'They added: ' + userInput : ''}
1661
 
1662
  Be BRIEF (3-5 sentences max). No headers, no bullet points, no explicit citations.
1663
 
1664
  Validate these as universal human needs. Share one insight about why these needs matter in connection. Optionally suggest what a request might sound like. Keep it warm and conversational - building emotional intelligence, not lecturing.
1665
 
1666
  Draw wisdom from NVC, attachment theory, and relational psychology - but embed it naturally, don't name sources.`,
1667
- verbose: isVerbose
1668
- })
 
 
 
 
 
1669
  });
1670
- const data = await response.json();
1671
- removeToolLoading();
1672
- if (data.response) {
1673
- addToolMsg('tool', data.response, 'Needs');
1674
- }
1675
  } catch (e) {
1676
  removeToolLoading();
1677
- addToolMsg('tool', 'Connection error.', 'Error');
 
1678
  }
1679
  input.value = '';
1680
  return;
@@ -1700,15 +1727,13 @@ Draw wisdom from NVC, attachment theory, and relational psychology - but embed i
1700
 
1701
  addToolLoading();
1702
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
 
1703
  try {
1704
- const response = await fetch('/api/tool', {
1705
- method: 'POST',
1706
- headers: { 'Content-Type': 'application/json' },
1707
- body: JSON.stringify({
1708
- tool: 'wisdom',
1709
- partner_message: lastPartnerMessage,
1710
- user_draft: userInput || '',
1711
- user_input: `The user draws wisdom from: ${selectedTradition}.
1712
 
1713
  Context:
1714
  ${contextText}
@@ -1716,16 +1741,17 @@ ${contextText}
1716
  ${userInput ? 'User adds: ' + userInput : ''}
1717
 
1718
  Based on the themes here (conflict, connection, fear, love, boundaries, etc.), provide ONE relevant quote from ${selectedTradition} that speaks to this situation.`,
1719
- verbose: isVerbose
1720
- })
 
 
 
 
 
1721
  });
1722
- const data = await response.json();
1723
- removeToolLoading();
1724
- if (data.response) {
1725
- addToolMsg('tool', data.response, `${selectedTradition} Wisdom`);
1726
- }
1727
  } catch (e) {
1728
  removeToolLoading();
 
1729
  addToolMsg('tool', 'Connection error.', 'Error');
1730
  }
1731
  input.value = '';
@@ -1820,28 +1846,27 @@ Based on the themes here (conflict, connection, fear, love, boundaries, etc.), p
1820
  // Partner starts
1821
  if (context) {
1822
  addLoading();
 
1823
  try {
1824
- const response = await fetch('/api/chat', {
1825
- method: 'POST',
1826
- headers: { 'Content-Type': 'application/json' },
1827
- body: JSON.stringify({
1828
- messages: [{ role: 'user', content: 'Start based on: ' + context }],
1829
- system: PARTNER_PROMPTS[style][difficulty] + ' CONTEXT: ' + context,
1830
- max_tokens: 200
1831
- })
 
 
1832
  });
1833
- const data = await response.json();
1834
- removeLoading();
1835
- if (data.content?.[0]) {
1836
- const msg = data.content[0].text;
1837
- addMessage('partner', msg);
1838
- lastPartnerMessage = msg;
1839
- conversationHistory.push({ role: 'assistant', content: msg });
1840
  }
1841
  } catch (e) {
1842
  removeLoading();
1843
  const msg = OPENING_MESSAGES[style][difficulty];
1844
- addMessage('partner', msg);
1845
  lastPartnerMessage = msg;
1846
  conversationHistory.push({ role: 'assistant', content: msg });
1847
  }
@@ -1878,28 +1903,26 @@ Based on the themes here (conflict, connection, fear, love, boundaries, etc.), p
1878
  conversationHistory.push({ role: 'user', content: message });
1879
 
1880
  addLoading();
 
1881
  try {
1882
  const style = document.getElementById('attachment-style').value;
1883
  const difficulty = document.getElementById('difficulty').value;
1884
 
1885
- const response = await fetch('/api/chat', {
1886
- method: 'POST',
1887
- headers: { 'Content-Type': 'application/json' },
1888
- body: JSON.stringify({
1889
- messages: conversationHistory,
1890
- system: PARTNER_PROMPTS[style][difficulty],
1891
- max_tokens: 300
1892
- })
 
 
1893
  });
1894
 
1895
- const data = await response.json();
1896
- removeLoading();
1897
-
1898
- if (data.content?.[0]) {
1899
- const partnerMsg = data.content[0].text;
1900
- addMessage('partner', partnerMsg);
1901
- lastPartnerMessage = partnerMsg;
1902
- conversationHistory.push({ role: 'assistant', content: partnerMsg });
1903
  }
1904
  } catch (error) {
1905
  removeLoading();
@@ -1961,6 +1984,7 @@ Based on the themes here (conflict, connection, fear, love, boundaries, etc.), p
1961
 
1962
  conv.appendChild(div);
1963
  conv.scrollTop = conv.scrollHeight;
 
1964
  }
1965
 
1966
  function addToolMsg(type, content, label = null) {
@@ -1979,6 +2003,7 @@ Based on the themes here (conflict, connection, fear, love, boundaries, etc.), p
1979
 
1980
  cont.appendChild(div);
1981
  cont.scrollTop = cont.scrollHeight;
 
1982
  }
1983
 
1984
  function addLoading() {
 
1126
  let lastPartnerMessage = '';
1127
  let conversationStarted = false;
1128
 
1129
+ // Streams a POST response's body as it arrives, calling onChunk with
1130
+ // each piece of text, and resolves with the full accumulated text.
1131
+ // Falls back to reading the whole body at once on error responses.
1132
+ async function streamFetch(url, body, onChunk) {
1133
+ const response = await fetch(url, {
1134
+ method: 'POST',
1135
+ headers: { 'Content-Type': 'application/json' },
1136
+ body: JSON.stringify(body)
1137
+ });
1138
+
1139
+ if (!response.ok) {
1140
+ let detail = 'Request failed.';
1141
+ try { detail = (await response.json()).detail || detail; } catch (e) {}
1142
+ throw new Error(detail);
1143
+ }
1144
+
1145
+ const reader = response.body.getReader();
1146
+ const decoder = new TextDecoder();
1147
+ let full = '';
1148
+ while (true) {
1149
+ const { done, value } = await reader.read();
1150
+ if (done) break;
1151
+ const chunk = decoder.decode(value, { stream: true });
1152
+ full += chunk;
1153
+ onChunk(chunk, full);
1154
+ }
1155
+ return full;
1156
+ }
1157
+
1158
  const TOOL_MAP = {
1159
  tend: { name: 'TEND Transform', icon: '✨', api: 'tend' },
1160
  soma: { name: 'Somatic Check-in', icon: '🫁', api: 'somatic_checkin' },
 
1602
  addToolLoading();
1603
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
1604
 
1605
+ let textEl = null;
1606
  try {
1607
+ const full = await streamFetch('/api/tool', {
1608
+ tool: apiTool,
1609
+ partner_message: lastPartnerMessage,
1610
+ user_draft: userInput,
1611
+ user_input: inputText,
1612
+ verbose: isVerbose
1613
+ }, (chunk, acc) => {
1614
+ if (!textEl) {
1615
+ removeToolLoading();
1616
+ textEl = addToolMsg('tool', '', TOOL_MAP[tool]?.name);
1617
+ }
1618
+ textEl.innerHTML = formatText(acc);
1619
  });
1620
 
1621
+ if (!full) {
1622
+ removeToolLoading();
 
 
 
 
1623
  addToolMsg('tool', 'Could not process. Check API.', 'Error');
1624
  }
1625
  } catch (error) {
1626
  removeToolLoading();
1627
+ if (textEl) { textEl.innerHTML = formatText('Connection error.'); }
1628
+ else { addToolMsg('tool', 'Connection error.', 'Error'); }
1629
  }
1630
  }
1631
 
 
1640
 
1641
  addToolLoading();
1642
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
1643
+ let textEl = null;
1644
  try {
1645
+ await streamFetch('/api/tool', {
1646
+ tool: 'feelings_needs',
1647
+ partner_message: lastPartnerMessage,
1648
+ user_draft: '',
1649
+ user_input: `The person identified these feelings: ${feelingsText}. ${userInput ? 'They added: ' + userInput : ''}
 
 
 
1650
 
1651
  Be BRIEF (3-5 sentences max). No headers, no bullet points, no explicit citations.
1652
 
1653
  Validate the feeling. Share one insight about what this feeling tends to signal in relationships or the nervous system. Name what need might be underneath. Keep it warm and conversational - building emotional intelligence, not lecturing.
1654
 
1655
  Draw wisdom from attachment neuroscience, somatic awareness, and relational psychology - but embed it naturally, don't name sources.`,
1656
+ verbose: isVerbose
1657
+ }, (chunk, acc) => {
1658
+ if (!textEl) {
1659
+ removeToolLoading();
1660
+ textEl = addToolMsg('tool', '', 'Feelings');
1661
+ }
1662
+ textEl.innerHTML = formatText(acc);
1663
  });
 
 
 
 
 
1664
  } catch (e) {
1665
  removeToolLoading();
1666
+ if (textEl) { textEl.innerHTML = formatText('Connection error.'); }
1667
+ else { addToolMsg('tool', 'Connection error.', 'Error'); }
1668
  }
1669
  input.value = '';
1670
  return;
 
1677
 
1678
  addToolLoading();
1679
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
1680
+ let textEl = null;
1681
  try {
1682
+ await streamFetch('/api/tool', {
1683
+ tool: 'feelings_needs',
1684
+ partner_message: lastPartnerMessage,
1685
+ user_draft: '',
1686
+ user_input: `The person identified these needs: ${needsText}. ${userInput ? 'They added: ' + userInput : ''}
 
 
 
1687
 
1688
  Be BRIEF (3-5 sentences max). No headers, no bullet points, no explicit citations.
1689
 
1690
  Validate these as universal human needs. Share one insight about why these needs matter in connection. Optionally suggest what a request might sound like. Keep it warm and conversational - building emotional intelligence, not lecturing.
1691
 
1692
  Draw wisdom from NVC, attachment theory, and relational psychology - but embed it naturally, don't name sources.`,
1693
+ verbose: isVerbose
1694
+ }, (chunk, acc) => {
1695
+ if (!textEl) {
1696
+ removeToolLoading();
1697
+ textEl = addToolMsg('tool', '', 'Needs');
1698
+ }
1699
+ textEl.innerHTML = formatText(acc);
1700
  });
 
 
 
 
 
1701
  } catch (e) {
1702
  removeToolLoading();
1703
+ if (textEl) { textEl.innerHTML = formatText('Connection error.'); }
1704
+ else { addToolMsg('tool', 'Connection error.', 'Error'); }
1705
  }
1706
  input.value = '';
1707
  return;
 
1727
 
1728
  addToolLoading();
1729
  const isVerbose = document.getElementById('toggle-verbose')?.checked || false;
1730
+ let textEl = null;
1731
  try {
1732
+ await streamFetch('/api/tool', {
1733
+ tool: 'wisdom',
1734
+ partner_message: lastPartnerMessage,
1735
+ user_draft: userInput || '',
1736
+ user_input: `The user draws wisdom from: ${selectedTradition}.
 
 
 
1737
 
1738
  Context:
1739
  ${contextText}
 
1741
  ${userInput ? 'User adds: ' + userInput : ''}
1742
 
1743
  Based on the themes here (conflict, connection, fear, love, boundaries, etc.), provide ONE relevant quote from ${selectedTradition} that speaks to this situation.`,
1744
+ verbose: isVerbose
1745
+ }, (chunk, acc) => {
1746
+ if (!textEl) {
1747
+ removeToolLoading();
1748
+ textEl = addToolMsg('tool', '', `${selectedTradition} Wisdom`);
1749
+ }
1750
+ textEl.innerHTML = formatText(acc);
1751
  });
 
 
 
 
 
1752
  } catch (e) {
1753
  removeToolLoading();
1754
+ if (textEl) { textEl.innerHTML = formatText('Connection error.'); return; }
1755
  addToolMsg('tool', 'Connection error.', 'Error');
1756
  }
1757
  input.value = '';
 
1846
  // Partner starts
1847
  if (context) {
1848
  addLoading();
1849
+ let textEl = null;
1850
  try {
1851
+ const full = await streamFetch('/api/chat', {
1852
+ messages: [{ role: 'user', content: 'Start based on: ' + context }],
1853
+ system: PARTNER_PROMPTS[style][difficulty] + ' CONTEXT: ' + context,
1854
+ max_tokens: 200
1855
+ }, (chunk, acc) => {
1856
+ if (!textEl) {
1857
+ removeLoading();
1858
+ textEl = addMessage('partner', '');
1859
+ }
1860
+ textEl.innerHTML = formatText(acc);
1861
  });
1862
+ if (full) {
1863
+ lastPartnerMessage = full;
1864
+ conversationHistory.push({ role: 'assistant', content: full });
 
 
 
 
1865
  }
1866
  } catch (e) {
1867
  removeLoading();
1868
  const msg = OPENING_MESSAGES[style][difficulty];
1869
+ if (textEl) { textEl.innerHTML = formatText(msg); } else { addMessage('partner', msg); }
1870
  lastPartnerMessage = msg;
1871
  conversationHistory.push({ role: 'assistant', content: msg });
1872
  }
 
1903
  conversationHistory.push({ role: 'user', content: message });
1904
 
1905
  addLoading();
1906
+ let textEl = null;
1907
  try {
1908
  const style = document.getElementById('attachment-style').value;
1909
  const difficulty = document.getElementById('difficulty').value;
1910
 
1911
+ const full = await streamFetch('/api/chat', {
1912
+ messages: conversationHistory,
1913
+ system: PARTNER_PROMPTS[style][difficulty],
1914
+ max_tokens: 300
1915
+ }, (chunk, acc) => {
1916
+ if (!textEl) {
1917
+ removeLoading();
1918
+ textEl = addMessage('partner', '');
1919
+ }
1920
+ textEl.innerHTML = formatText(acc);
1921
  });
1922
 
1923
+ if (full) {
1924
+ lastPartnerMessage = full;
1925
+ conversationHistory.push({ role: 'assistant', content: full });
 
 
 
 
 
1926
  }
1927
  } catch (error) {
1928
  removeLoading();
 
1984
 
1985
  conv.appendChild(div);
1986
  conv.scrollTop = conv.scrollHeight;
1987
+ return text;
1988
  }
1989
 
1990
  function addToolMsg(type, content, label = null) {
 
2003
 
2004
  cont.appendChild(div);
2005
  cont.scrollTop = cont.scrollHeight;
2006
+ return text;
2007
  }
2008
 
2009
  function addLoading() {