File size: 16,299 Bytes
6f53717
 
259b50c
 
91c7b38
6f53717
 
 
505d6d9
6f53717
 
 
 
305d870
6f53717
ed64977
ddac050
1f75665
6f53717
 
 
 
 
 
 
 
 
 
 
 
 
 
7409b02
6f53717
 
 
 
 
 
 
91c7b38
6f53717
 
 
 
 
 
 
 
 
7409b02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f53717
 
 
 
 
 
 
 
91c7b38
259b50c
91c7b38
 
 
6f53717
 
 
 
 
 
 
 
 
 
 
 
7409b02
6f53717
 
7409b02
6f53717
 
 
 
 
 
 
 
 
 
 
7409b02
 
 
 
 
 
 
6f53717
 
7409b02
6f53717
91c7b38
 
 
 
 
 
 
 
 
259b50c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f53717
 
 
 
7409b02
6f53717
 
 
259b50c
 
 
 
6f53717
259b50c
 
 
 
 
 
 
6f53717
7409b02
6f53717
 
 
 
1f75665
6f53717
 
 
 
 
 
 
 
 
 
 
91c7b38
 
 
 
6f53717
 
91c7b38
6f53717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7409b02
 
6f53717
91c7b38
6f53717
 
 
 
909eff0
6f53717
 
 
 
 
 
 
 
7409b02
 
 
909eff0
6f53717
 
 
 
 
 
 
 
 
 
 
91c7b38
6f53717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7409b02
 
 
6f53717
 
 
 
 
1f75665
6f53717
 
 
 
 
 
 
7409b02
6f53717
 
 
 
 
 
 
 
 
 
 
 
 
 
7409b02
6f53717
 
 
 
 
7409b02
6f53717
 
 
 
 
 
7409b02
6f53717
7409b02
6f53717
 
 
 
 
 
 
 
 
 
7409b02
 
 
 
 
 
 
 
 
6f53717
 
 
 
 
 
 
1f75665
505d6d9
7409b02
 
 
 
 
 
 
6f53717
7409b02
6f53717
505d6d9
 
 
 
6f53717
 
 
 
 
505d6d9
6f53717
505d6d9
6f53717
505d6d9
7409b02
6f53717
 
 
 
 
 
e1d36ec
505d6d9
6f53717
505d6d9
6f53717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1d36ec
6f53717
 
 
1c5cb1e
5492f08
7409b02
 
 
 
6f53717
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
"""
MindStudio WebSocket Streaming Client — Flask API Endpoint
FIXED: Race condition — AI was answering previous message (buffer drain fix)
FIXED: Per-thread continuation tokens
FIXED: WebSocket compatibility
"""

import asyncio
import json
import httpx
import websockets
import os
import threading

from flask import Flask, request, Response, jsonify, stream_with_context

app = Flask(__name__)

# ─────────────────────────────────────────────
# CONFIGURATION
# ─────────────────────────────────────────────

ORG_ID = os.getenv("MINDSTUDIO_ORG_ID", "9235df37-94ad-47a2-92f7-bbf28ea8d4d1")
APP_ID = os.getenv("MINDSTUDIO_APP_ID", "75a42c47-67b3-4c9d-a23f-65998823d266")
INITIAL_CONTINUATION_TOKEN = os.getenv(
    "MINDSTUDIO_CONTINUATION_TOKEN",
    "155b2f71-9738-4260-8849-46e3eb0dd384::2b71a3fd-f4f0-4439-8163-60cd53d28218"
)
TARGET_MODEL = "claude-4-7-opus"
WS_URL = "wss://api-socket.mindstudio.ai/"

# ─────────────────────────────────────────────
# GLOBAL STATE
# ─────────────────────────────────────────────

_clients: dict = {}
_clients_lock = threading.Lock()


# ─────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────

def safe_get_model(debug_info: dict):
    model_settings = debug_info.get("modelSettings")
    if isinstance(model_settings, dict):
        return model_settings.get("model")
    return None


def check_ws_open(ws) -> bool:
    if ws is None:
        return False
    try:
        if hasattr(ws, 'close_code'):
            return ws.close_code is None
        if hasattr(ws, 'closed'):
            return not ws.closed
        if hasattr(ws, 'state'):
            import websockets.connection
            return ws.state == websockets.connection.State.OPEN
        return True
    except Exception:
        return False


# ─────────────────────────────────────────────
# MINDSTUDIO CLIENT
# ─────────────────────────────────────────────

class MindStudioClient:
    def __init__(self, access_token: str, model: str = TARGET_MODEL):
        self.access_token = access_token
        self.model = model

        # Per-thread continuation tokens
        self._continuation_tokens: dict = {}
        self._tokens_lock = threading.Lock()

        self.ws = None
        self.loop = asyncio.new_event_loop()

        self._thread = threading.Thread(target=self._run_loop, daemon=True)
        self._thread.start()

    def _run_loop(self):
        asyncio.set_event_loop(self.loop)
        self.loop.run_forever()

    def connect_sync(self):
        future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)
        future.result(timeout=20)

    async def _connect(self):
        print(f"[WS] Connecting to {WS_URL} ...")
        extra_headers = {
            "Origin": "https://app.mindstudio.ai",
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
        }
        self.ws = await websockets.connect(
            WS_URL,
            subprotocols=["auth", self.access_token],
            additional_headers=extra_headers,
            ping_interval=None,
            ping_timeout=None,
        )
        print(f"[WS] Connected! Type: {type(self.ws).__name__}")
        print(f"[WS] Subprotocol: {self.ws.subprotocol}")

        has_closed    = hasattr(self.ws, 'closed')
        has_closecode = hasattr(self.ws, 'close_code')
        has_state     = hasattr(self.ws, 'state')
        print(f"[WS] Attrs → closed={has_closed} close_code={has_closecode} state={has_state}")

    def is_connected(self) -> bool:
        return check_ws_open(self.ws)

    def get_continuation_token(self, thread_id: str) -> str:
        with self._tokens_lock:
            return self._continuation_tokens.get(thread_id, INITIAL_CONTINUATION_TOKEN)

    def set_continuation_token(self, thread_id: str, token: str):
        with self._tokens_lock:
            self._continuation_tokens[thread_id] = token
            print(f"[TOKEN] Thread {thread_id[:8]}... token updated")

    # ─────────────────────────────────────────
    # ✅ FIX: Drain leftover WS buffer
    # ─────────────────────────────────────────
    async def _drain_ws_buffer(self):
        """Flush any leftover WebSocket messages from a previous response."""
        flushed = 0
        try:
            while True:
                raw = await asyncio.wait_for(self.ws.recv(), timeout=0.05)
                flushed += 1
                print(f"[DRAIN] Flushed leftover msg #{flushed}: {str(raw)[:80]}...")
        except asyncio.TimeoutError:
            pass
        except Exception:
            pass
        if flushed:
            print(f"[DRAIN] Total flushed: {flushed} leftover message(s)")

    # ─────────────────────────────────────────
    # ✅ FIX: Post FIRST, then listen
    # ─────────────────────────────────────────
    def send_message_stream(self, message: str, thread_id: str):
        import queue

        chunk_queue = queue.Queue()
        done_event  = threading.Event()

        async def _run():
            try:
                # ✅ STEP 1: Drain any leftover buffer from previous message
                await self._drain_ws_buffer()

                # ✅ STEP 2: Post the new message
                await self._post_message(message, thread_id)

                # ✅ STEP 3: Small wait for server to process
                await asyncio.sleep(0.15)

                # ✅ STEP 4: Listen for THIS message's response only
                await self._listen_for_response(chunk_queue, done_event, thread_id)

            except Exception as e:
                print(f"[ERROR] send_message_stream: {e}")
                chunk_queue.put(f"[ERROR] {e}")
                done_event.set()

        asyncio.run_coroutine_threadsafe(_run(), self.loop)

        while not done_event.is_set() or not chunk_queue.empty():
            try:
                chunk = chunk_queue.get(timeout=0.5)
                yield f"data: {json.dumps({'text': chunk})}\n\n"
            except Exception:
                continue

        yield f"data: {json.dumps({'done': True})}\n\n"

    async def _post_message(self, message: str, thread_id: str):
        post_url = f"https://v1.mindstudio-api.com/v1/apps/load/{APP_ID}/threads/post"

        continuation_token = self.get_continuation_token(thread_id)
        print(f"[POST] Using token: {continuation_token[:30]}... for thread: {thread_id[:8]}...")

        payload = {
            "threadId": thread_id,
            "continuationToken": continuation_token,
            "payload": {
                "message": message,
                "imageUrl": "",
                "modelCandidates": [self.model],
            },
        }
        headers = {
            "Authorization": f"Bearer: {self.access_token}",
            "X-Organization-Id": ORG_ID,
            "Content-Type": "application/json",
            "Accept": "application/json",
            "Origin": "https://app.mindstudio.ai",
            "User-Agent": "Mozilla/5.0",
            "Referer": "https://app.mindstudio.ai/",
        }
        async with httpx.AsyncClient() as client:
            resp = await client.post(post_url, json=payload, headers=headers)
        print(f"[POST] Status: {resp.status_code}")
        if resp.status_code not in (200, 204):
            print(f"[POST] Body: {resp.text}")

    async def _listen_for_response(self, chunk_queue, done_event, thread_id: str):
        full_response = ""
        previous_content_length = 0
        system_post_id = None
        detected_model = None

        while True:
            try:
                raw_message = await asyncio.wait_for(self.ws.recv(), timeout=30.0)
            except asyncio.TimeoutError:
                continue
            except websockets.exceptions.ConnectionClosed as e:
                print(f"[WS] Connection closed: {e}")
                break
            except Exception as e:
                print(f"[WS] Recv error: {e}")
                break

            try:
                event = json.loads(raw_message)
            except json.JSONDecodeError:
                continue

            event_type = event.get("type", "")

            if event_type == "Apps/ThreadContinuationActionUpdated":
                data = event.get("appThreadContinuationActionUpdated", {})
                new_token = data.get("continuationAction", {}).get("token")
                if new_token:
                    self.set_continuation_token(thread_id, new_token)

                underlying = (
                    data.get("continuationAction", {})
                    .get("step", {})
                    .get("underlyingModel", {})
                )
                if isinstance(underlying, dict) and underlying.get("model"):
                    if not detected_model:
                        detected_model = underlying["model"]

            elif event_type == "Apps/ThreadPostsCreated":
                posts = event.get("appThreadPostsCreated", {}).get("posts", [])
                for post in posts:
                    chat_msg = post.get("chatMessage", {})
                    if chat_msg.get("source") == "system":
                        system_post_id = post.get("id")

                        debug_info = chat_msg.get("_debugInfo", {})
                        if isinstance(debug_info, dict):
                            model = safe_get_model(debug_info)
                            if model:
                                detected_model = model

                        content = chat_msg.get("content", "")
                        is_in_progress = chat_msg.get("isInProgress", True)

                        if content and not is_in_progress:
                            chunk_queue.put(content)
                            done_event.set()
                            return
                        elif content:
                            chunk_queue.put(content)
                            previous_content_length = len(content)
                            full_response = content

            elif event_type == "Apps/ThreadPostUpdated":
                post_data = event.get("appThreadPostUpdated", {})
                new_post  = post_data.get("newPost", {})
                post_id   = post_data.get("postId")
                chat_msg  = new_post.get("chatMessage", {})

                if chat_msg.get("source") != "system":
                    continue
                if system_post_id and post_id != system_post_id:
                    continue

                debug_info = chat_msg.get("_debugInfo", {})
                if isinstance(debug_info, dict):
                    model = safe_get_model(debug_info)
                    if model and not detected_model:
                        detected_model = model

                current_content = chat_msg.get("content", "")
                is_in_progress  = chat_msg.get("isInProgress", True)

                if len(current_content) > previous_content_length:
                    delta = current_content[previous_content_length:]
                    chunk_queue.put(delta)
                    previous_content_length = len(current_content)
                    full_response = current_content

                if not is_in_progress:
                    print(f"[DONE] Model: {detected_model or 'unknown'}")
                    done_event.set()
                    return

        done_event.set()

    async def _close(self):
        if self.ws:
            await self.ws.close()


# ─────────────────────────────────────────────
# HELPER: get or create client
# ─────────────────────────────────────────────

def get_or_create_client(access_token: str) -> MindStudioClient:
    with _clients_lock:
        client = _clients.get(access_token)
        if client and client.is_connected():
            print("[CLIENT] Reusing existing connected client")
            return client
        print("[CLIENT] Creating new client and connecting...")
        client = MindStudioClient(access_token=access_token)
        client.connect_sync()
        _clients[access_token] = client
        return client


# ─────────────────────────────────────────────
# ROUTES
# ─────────────────────────────────────────────

@app.route("/health", methods=["GET"])
def health():
    return jsonify({
        "status": "ok",
        "model": TARGET_MODEL,
        "websockets_version": websockets.__version__,
    }), 200


@app.route("/connect", methods=["GET"])
def connect():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return jsonify({"error": "Missing or invalid Authorization header"}), 401

    access_token = auth.removeprefix("Bearer ").strip()

    try:
        client = get_or_create_client(access_token)
        ws_type = type(client.ws).__name__ if client.ws else "None"
        return jsonify({
            "status": "connected",
            "model": TARGET_MODEL,
            "ws_object_type": ws_type,
        }), 200
    except Exception as e:
        print(f"[CONNECT ERROR] {type(e).__name__}: {e}")
        return jsonify({"error": str(e)}), 500


@app.route("/chat", methods=["POST"])
def chat():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return jsonify({"error": "Missing or invalid Authorization header"}), 401

    access_token = auth.removeprefix("Bearer ").strip()

    data = request.get_json(silent=True)
    if not data:
        return jsonify({"error": "Invalid JSON body"}), 400

    message   = data.get("message", "").strip()
    thread_id = data.get("threadId", "").strip()

    if not message:
        return jsonify({"error": "Missing 'message' field"}), 400
    if not thread_id:
        return jsonify({"error": "Missing 'threadId' field"}), 400

    try:
        client = get_or_create_client(access_token)
    except Exception as e:
        return jsonify({"error": f"WebSocket connection failed: {e}"}), 500

    def generate():
        yield f"data: {json.dumps({'status': 'connected', 'model': TARGET_MODEL})}\n\n"
        for chunk in client.send_message_stream(message, thread_id):
            yield chunk

    return Response(
        stream_with_context(generate()),
        mimetype="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
            "Connection": "keep-alive",
        },
    )


# ─────────────────────────────────────────────
# ENTRY POINT
# ─────────────────────────────────────────────

if __name__ == "__main__":
    print("="*60)
    print(f"  MindStudio Flask API  |  Model: {TARGET_MODEL}")
    print(f"  websockets version: {websockets.__version__}")
    print("="*60)
    app.run(host="0.0.0.0", port=7860, debug=False, threaded=True)