tina4-python 3.10.69__tar.gz → 3.10.70__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. {tina4_python-3.10.69 → tina4_python-3.10.70}/PKG-INFO +1 -1
  2. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/CLAUDE.md +3 -3
  3. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/response.py +27 -1
  5. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/server.py +74 -13
  6. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/dev_admin/__init__.py +4 -2
  7. tina4_python-3.10.70/tina4_python/public/js/tina4-dev-admin.js +329 -0
  8. {tina4_python-3.10.69 → tina4_python-3.10.70}/.gitignore +0 -0
  9. {tina4_python-3.10.69 → tina4_python-3.10.70}/README.md +0 -0
  10. {tina4_python-3.10.69 → tina4_python-3.10.70}/pyproject.toml +0 -0
  11. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/HtmlElement.py +0 -0
  12. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/Testing.py +0 -0
  13. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/ai/__init__.py +0 -0
  14. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/api/__init__.py +0 -0
  15. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/auth/__init__.py +0 -0
  16. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/cache/__init__.py +0 -0
  17. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/cli/__init__.py +0 -0
  18. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/__init__.py +0 -0
  20. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/cache.py +0 -0
  21. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/constants.py +0 -0
  22. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/events.py +0 -0
  23. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/middleware.py +0 -0
  24. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/request.py +0 -0
  25. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/core/router.py +0 -0
  26. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/connection.py +0 -0
  30. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/firebird.py +0 -0
  31. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/mongodb.py +0 -0
  32. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/mssql.py +0 -0
  33. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/mysql.py +0 -0
  34. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/odbc.py +0 -0
  35. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/postgres.py +0 -0
  36. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/database/sqlite.py +0 -0
  37. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/debug/__init__.py +0 -0
  38. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/debug/error_overlay.py +0 -0
  39. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/dev_reload.py +0 -0
  41. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/dotenv/__init__.py +0 -0
  42. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/frond/FROND.md +0 -0
  43. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/frond/__init__.py +0 -0
  44. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/frond/engine.py +0 -0
  45. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/auth/meta.json +0 -0
  46. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  47. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/database/meta.json +0 -0
  48. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  49. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/error-overlay/meta.json +0 -0
  50. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  51. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/orm/meta.json +0 -0
  52. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  53. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  54. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/queue/meta.json +0 -0
  55. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  56. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/rest-api/meta.json +0 -0
  57. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  58. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/templates/meta.json +0 -0
  59. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  60. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  61. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/graphql/__init__.py +0 -0
  62. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/i18n/__init__.py +0 -0
  63. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/mcp/__init__.py +0 -0
  64. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/mcp/protocol.py +0 -0
  65. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/mcp/tools.py +0 -0
  66. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/messenger/__init__.py +0 -0
  67. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/migration/__init__.py +0 -0
  68. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/migration/runner.py +0 -0
  69. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/orm/__init__.py +0 -0
  70. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/orm/fields.py +0 -0
  71. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/orm/model.py +0 -0
  72. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/css/tina4.css +0 -0
  73. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/css/tina4.min.css +0 -0
  74. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/favicon.ico +0 -0
  75. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/images/logo.svg +0 -0
  76. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  77. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/js/frond.min.js +0 -0
  78. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  79. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/js/tina4.min.js +0 -0
  80. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/js/tina4js.min.js +0 -0
  81. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/swagger/index.html +0 -0
  82. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  83. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/query_builder/__init__.py +0 -0
  84. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/queue/__init__.py +0 -0
  85. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/queue_backends/__init__.py +0 -0
  86. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/queue_backends/kafka_backend.py +0 -0
  87. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/queue_backends/mongo_backend.py +0 -0
  88. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  89. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/__init__.py +0 -0
  90. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  91. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_badges.scss +0 -0
  92. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  93. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_cards.scss +0 -0
  94. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_forms.scss +0 -0
  95. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_grid.scss +0 -0
  96. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_modals.scss +0 -0
  97. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_nav.scss +0 -0
  98. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_reset.scss +0 -0
  99. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_tables.scss +0 -0
  100. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_typography.scss +0 -0
  101. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  102. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/_variables.scss +0 -0
  103. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/base.scss +0 -0
  104. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/colors.scss +0 -0
  105. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/scss/tina4css/tina4.scss +0 -0
  106. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/seeder/__init__.py +0 -0
  107. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/service/__init__.py +0 -0
  108. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/session/__init__.py +0 -0
  109. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/session_handlers/__init__.py +0 -0
  110. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  111. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/session_handlers/redis_handler.py +0 -0
  112. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/session_handlers/valkey_handler.py +0 -0
  113. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/swagger/__init__.py +0 -0
  114. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/components/crud.twig +0 -0
  115. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  116. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  117. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/docker/python/Dockerfile +0 -0
  118. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  119. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/302.twig +0 -0
  120. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/401.twig +0 -0
  121. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/403.twig +0 -0
  122. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/404.twig +0 -0
  123. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/500.twig +0 -0
  124. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/502.twig +0 -0
  125. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/503.twig +0 -0
  126. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/errors/base.twig +0 -0
  127. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/frontend/README.md +0 -0
  128. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/templates/readme.md +0 -0
  129. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/test_client/__init__.py +0 -0
  130. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  131. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  132. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  133. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  134. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  135. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  136. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  137. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  138. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  139. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  140. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  141. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  142. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/validator/__init__.py +0 -0
  143. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/websocket/__init__.py +0 -0
  144. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/websocket/backplane.py +0 -0
  145. {tina4_python-3.10.69 → tina4_python-3.10.70}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.10.69
3
+ Version: 3.10.70
4
4
  Summary: Tina4 for Python — 54 built-in features, zero dependencies
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -470,6 +470,7 @@ return response("Not found", 404) # With status code
470
470
  return response.redirect("/login") # Redirect
471
471
  return response.render("page.twig", data) # Render Twig template
472
472
  return response.file("doc.pdf") # Serve a file
473
+ return response.stream(generator) # SSE/streaming response (text/event-stream)
473
474
  ```
474
475
 
475
476
  Add custom headers before returning:
@@ -1767,7 +1768,7 @@ async def dashboard(request, response):
1767
1768
 
1768
1769
  ## v3 Features Summary
1769
1770
 
1770
- - **54 built-in features**, zero third-party dependencies
1771
+ - **55 built-in features**, zero third-party dependencies
1771
1772
  - **2,066 tests** passing across all modules
1772
1773
  - **Production server auto-detect**: `tina4python serve --production` auto-installs uvicorn
1773
1774
  - **`tina4python generate`**: model, route, migration, middleware scaffolding
@@ -1783,5 +1784,4 @@ async def dashboard(request, response):
1783
1784
  - **SameSite=Lax** default on session cookies (`TINA4_SESSION_SAMESITE`)
1784
1785
  - **`tina4 init`** generates Dockerfile and .dockerignore
1785
1786
  - **Gallery**: 7 interactive examples with Try It deploy at `/__dev/`
1786
-
1787
-
1787
+ - **SSE/Streaming**: `response.stream()` for Server-Sent Events — pass an async generator, framework handles chunked transfer encoding and keep-alive
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.10.69"
11
+ __version__ = "3.10.70"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -75,7 +75,7 @@ class Response:
75
75
 
76
76
  __slots__ = (
77
77
  "status_code", "content", "content_type",
78
- "_headers", "_cookies",
78
+ "_headers", "_cookies", "_is_streaming", "_stream_source",
79
79
  )
80
80
 
81
81
  def __init__(self):
@@ -84,6 +84,8 @@ class Response:
84
84
  self.content_type: str = "text/html; charset=utf-8"
85
85
  self._headers: list[tuple[str, str]] = []
86
86
  self._cookies: list[str] = []
87
+ self._is_streaming: bool = False
88
+ self._stream_source = None
87
89
 
88
90
  def __call__(self, data=None, status_code: int = 200, content_type: str = None) -> "Response":
89
91
  """Smart callable — auto-detects content type from data.
@@ -156,6 +158,30 @@ class Response:
156
158
  self._cookies.append("; ".join(parts))
157
159
  return self
158
160
 
161
+ def stream(self, source, content_type: str = "text/event-stream") -> "Response":
162
+ """Stream response from an async generator or sync iterable.
163
+
164
+ Usage (SSE):
165
+ @get("/events")
166
+ async def events(request, response):
167
+ async def generate():
168
+ for i in range(5):
169
+ yield f"data: message {i}\\n\\n"
170
+ await asyncio.sleep(1)
171
+ return response.stream(generate())
172
+
173
+ Usage (custom content type):
174
+ return response.stream(generate(), "application/octet-stream")
175
+ """
176
+ self._is_streaming = True
177
+ self._stream_source = source
178
+ self.content_type = content_type
179
+ if content_type == "text/event-stream":
180
+ self._headers.append(("Cache-Control", "no-cache"))
181
+ self._headers.append(("Connection", "keep-alive"))
182
+ self._headers.append(("X-Accel-Buffering", "no"))
183
+ return self
184
+
159
185
  def json(self, data, status_code: int = None) -> "Response":
160
186
  """JSON response."""
161
187
  if status_code:
@@ -674,7 +674,13 @@ def _handle_rate_limit(request: Request, response: Response) -> Response | None:
674
674
  async def _handle_dev_admin(request: Request, response: Response) -> Response:
675
675
  """Serve the /__dev dashboard and API routes."""
676
676
  from tina4_python.dev_admin import get_api_handlers, render_dashboard
677
- if request.path in ("/__dev/", "/__dev"):
677
+ if request.path in ("/__dev/v2", "/__dev/v2/"):
678
+ # New unified SPA dev admin
679
+ response.html("""<!DOCTYPE html>
680
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head>
681
+ <body><div id="app" data-framework="python" data-color="#3b82f6"></div>
682
+ <script src="/js/tina4-dev-admin.js"></script></body></html>""")
683
+ elif request.path in ("/__dev/", "/__dev"):
678
684
  response.html(render_dashboard())
679
685
  else:
680
686
  handlers = get_api_handlers()
@@ -1043,12 +1049,43 @@ async def app(scope: dict, receive, send):
1043
1049
  request = Request.from_scope(scope, body)
1044
1050
  response = await handle(request)
1045
1051
 
1052
+ # Streaming responses bypass ETag/compression — send immediately
1053
+ _streaming = getattr(response, "_is_streaming", False)
1054
+ if _streaming:
1055
+ # Streaming response — send headers then stream chunks
1056
+ stream_headers = [
1057
+ (b"content-type", response.content_type.encode()),
1058
+ ]
1059
+ for name, value in response._headers:
1060
+ stream_headers.append((name.lower().encode(), value.encode()))
1061
+ for cookie_str in response._cookies:
1062
+ stream_headers.append((b"set-cookie", cookie_str.encode()))
1063
+ await send({"type": "http.response.start", "status": response.status_code, "headers": stream_headers})
1064
+
1065
+ import asyncio
1066
+ source = response._stream_source
1067
+ if hasattr(source, "__aiter__"):
1068
+ # Async generator
1069
+ async for chunk in source:
1070
+ if isinstance(chunk, str):
1071
+ chunk = chunk.encode()
1072
+ await send({"type": "http.response.body", "body": chunk, "more_body": True})
1073
+ elif hasattr(source, "__iter__"):
1074
+ # Sync iterable
1075
+ for chunk in source:
1076
+ if isinstance(chunk, str):
1077
+ chunk = chunk.encode()
1078
+ await send({"type": "http.response.body", "body": chunk, "more_body": True})
1079
+ await asyncio.sleep(0) # yield control
1080
+
1081
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
1082
+ return
1083
+
1046
1084
  # ETag check — 304 Not Modified
1047
1085
  if_none_match = request.headers.get("if-none-match", "")
1048
1086
  accept_encoding = request.headers.get("accept-encoding", "")
1049
1087
  headers = response.build_headers(accept_encoding)
1050
1088
 
1051
- # Check ETag after building (since build_headers computes it)
1052
1089
  etag = ""
1053
1090
  for name, value in headers:
1054
1091
  if name == b"etag":
@@ -1503,26 +1540,50 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1503
1540
  async def receive():
1504
1541
  return {"type": "http.request", "body": body, "more_body": False}
1505
1542
 
1543
+ _headers_sent = False
1544
+
1506
1545
  async def send(msg):
1507
- nonlocal resp_started, resp_status, resp_headers, resp_body
1546
+ nonlocal resp_started, resp_status, resp_headers, resp_body, _headers_sent
1508
1547
  if msg["type"] == "http.response.start":
1509
1548
  resp_started = True
1510
1549
  resp_status = msg["status"]
1511
1550
  resp_headers = msg.get("headers", [])
1512
1551
  elif msg["type"] == "http.response.body":
1513
- resp_body = msg.get("body", b"")
1552
+ chunk = msg.get("body", b"")
1553
+ more = msg.get("more_body", False)
1554
+
1555
+ if more or _headers_sent:
1556
+ # Streaming mode — flush headers on first chunk, then write each chunk immediately
1557
+ if not _headers_sent:
1558
+ _headers_sent = True
1559
+ writer.write(f"HTTP/1.1 {resp_status} OK\r\n".encode())
1560
+ for name, value in resp_headers:
1561
+ writer.write(name + b": " + value + b"\r\n")
1562
+ writer.write(b"\r\n")
1563
+ await writer.drain()
1564
+
1565
+ if chunk:
1566
+ writer.write(chunk)
1567
+ await writer.drain()
1568
+
1569
+ if not more:
1570
+ writer.close()
1571
+ else:
1572
+ # Buffered mode — accumulate body
1573
+ resp_body = chunk
1514
1574
 
1515
1575
  await app(scope, receive, send)
1516
1576
 
1517
- # Write HTTP/1.1 response
1518
- status_line = f"HTTP/1.1 {resp_status} OK\r\n"
1519
- writer.write(status_line.encode())
1520
- for name, value in resp_headers:
1521
- writer.write(name + b": " + value + b"\r\n")
1522
- writer.write(b"\r\n")
1523
- writer.write(resp_body)
1524
- await writer.drain()
1525
- writer.close()
1577
+ # Write HTTP/1.1 response (only if headers weren't already sent by streaming)
1578
+ if not _headers_sent:
1579
+ status_line = f"HTTP/1.1 {resp_status} OK\r\n"
1580
+ writer.write(status_line.encode())
1581
+ for name, value in resp_headers:
1582
+ writer.write(name + b": " + value + b"\r\n")
1583
+ writer.write(b"\r\n")
1584
+ writer.write(resp_body)
1585
+ await writer.drain()
1586
+ writer.close()
1526
1587
 
1527
1588
  server = await start_server(_handle_connection, host, port)
1528
1589
 
@@ -515,11 +515,13 @@ async def _api_query(request, response):
515
515
  is_read = upper.startswith("SELECT") or upper.startswith("PRAGMA") or upper.startswith("SHOW") or upper.startswith("DESCRIBE")
516
516
 
517
517
  if is_read:
518
- result = db.fetch(statements[0])
518
+ limit = int(body.get("limit", 100))
519
+ offset = int(body.get("offset", 0))
520
+ result = db.fetch(statements[0], limit=limit, offset=offset)
519
521
  data = result.records
520
522
  MessageLog.log("query", f"SQL: {statements[0][:80]}", {"rows": result.count}, level="info")
521
523
  db.close()
522
- return response({"rows": data, "count": result.count})
524
+ return response({"rows": data, "count": result.count, "limit": limit, "offset": offset})
523
525
 
524
526
  # Execute all statements (single write or multi-statement batch)
525
527
  total_affected = 0
@@ -0,0 +1,329 @@
1
+ (function(){"use strict";const Z={python:{color:"#3b82f6",name:"Python"},php:{color:"#8b5cf6",name:"PHP"},ruby:{color:"#ef4444",name:"Ruby"},nodejs:{color:"#22c55e",name:"Node.js"}};function Et(){const t=document.getElementById("app"),n=(t==null?void 0:t.dataset.framework)??"python",o=t==null?void 0:t.dataset.color,r=Z[n]??Z.python;return{framework:n,color:o??r.color,name:r.name}}function Tt(t){const n=document.documentElement;n.style.setProperty("--primary",t.color),n.style.setProperty("--bg","#0f172a"),n.style.setProperty("--surface","#1e293b"),n.style.setProperty("--border","#334155"),n.style.setProperty("--text","#e2e8f0"),n.style.setProperty("--muted","#94a3b8"),n.style.setProperty("--success","#22c55e"),n.style.setProperty("--danger","#ef4444"),n.style.setProperty("--warn","#f59e0b"),n.style.setProperty("--info","#3b82f6")}const Mt=`
2
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); }
4
+
5
+ .dev-admin { display: flex; flex-direction: column; height: 100vh; }
6
+ .dev-header { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; background: var(--surface); border-bottom: 1px solid var(--border); }
7
+ .dev-header h1 { font-size: 1rem; font-weight: 700; }
8
+ .dev-header h1 span { color: var(--primary); }
9
+
10
+ .dev-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); background: var(--surface); padding: 0 0.5rem; overflow-x: auto; }
11
+ .dev-tab { padding: 0.5rem 0.75rem; border: none; background: none; color: var(--muted); cursor: pointer; font-size: 0.8rem; font-weight: 500; white-space: nowrap; border-bottom: 2px solid transparent; transition: all 0.15s; }
12
+ .dev-tab:hover { color: var(--text); }
13
+ .dev-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
14
+
15
+ .dev-content { flex: 1; overflow-y: auto; }
16
+ .dev-panel { padding: 1rem; display: none; }
17
+ .dev-panel.active { display: block; }
18
+ .dev-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
19
+ .dev-panel-header h2 { font-size: 0.95rem; font-weight: 600; }
20
+
21
+ .btn { padding: 0.35rem 0.75rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--surface); color: var(--text); cursor: pointer; font-size: 0.8rem; transition: all 0.15s; height: 30px; line-height: 1; }
22
+ .btn:hover { background: var(--border); }
23
+ .btn-primary { background: var(--primary); border-color: var(--primary); color: white; }
24
+ .btn-primary:hover { opacity: 0.9; }
25
+ .btn-danger { background: var(--danger); border-color: var(--danger); color: white; }
26
+ .btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; }
27
+
28
+ .input { padding: 0.35rem 0.5rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--bg); color: var(--text); font-size: 0.8rem; height: 30px; }
29
+ select.input { height: 30px; }
30
+ .input:focus { outline: none; border-color: var(--primary); }
31
+ textarea.input { font-family: "SF Mono", "Fira Code", Consolas, monospace; resize: vertical; height: auto; }
32
+
33
+ table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
34
+ th { text-align: left; padding: 0.5rem; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); }
35
+ td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
36
+ tr:hover { background: rgba(255,255,255,0.03); }
37
+
38
+ .badge { display: inline-block; padding: 0.1rem 0.4rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
39
+ .badge-success { background: rgba(34,197,94,0.15); color: var(--success); }
40
+ .badge-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
41
+ .badge-warn { background: rgba(245,158,11,0.15); color: var(--warn); }
42
+ .badge-info { background: rgba(59,130,246,0.15); color: var(--info); }
43
+ .badge-muted { background: rgba(148,163,184,0.15); color: var(--muted); }
44
+
45
+ .method { font-weight: 700; font-size: 0.7rem; padding: 0.1rem 0.3rem; border-radius: 0.2rem; }
46
+ .method-get { color: var(--success); }
47
+ .method-post { color: var(--info); }
48
+ .method-put { color: var(--warn); }
49
+ .method-patch { color: var(--warn); }
50
+ .method-delete { color: var(--danger); }
51
+ .method-any { color: var(--muted); }
52
+
53
+ .flex { display: flex; }
54
+ .gap-sm { gap: 0.5rem; }
55
+ .items-center { align-items: center; }
56
+ .text-mono { font-family: "SF Mono", "Fira Code", Consolas, monospace; }
57
+ .text-sm { font-size: 0.8rem; }
58
+ .text-muted { color: var(--muted); }
59
+ .empty-state { text-align: center; padding: 2rem; color: var(--muted); }
60
+
61
+ .metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
62
+ .metric-card { background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0.75rem; }
63
+ .metric-card .label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
64
+ .metric-card .value { font-size: 1.5rem; font-weight: 700; margin-top: 0.25rem; }
65
+
66
+ .chat-container { display: flex; flex-direction: column; height: calc(100vh - 140px); }
67
+ .chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
68
+ .chat-msg { padding: 0.5rem 0.75rem; border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.85rem; line-height: 1.5; max-width: 85%; }
69
+ .chat-user { background: var(--primary); color: white; margin-left: auto; }
70
+ .chat-bot { background: var(--surface); border: 1px solid var(--border); }
71
+ .chat-input-row { display: flex; gap: 0.5rem; padding: 0.75rem; border-top: 1px solid var(--border); }
72
+ .chat-input-row input { flex: 1; }
73
+
74
+ .error-trace { background: var(--bg); border: 1px solid var(--border); border-radius: 0.375rem; padding: 0.5rem; font-family: monospace; font-size: 0.75rem; white-space: pre-wrap; max-height: 200px; overflow-y: auto; margin-top: 0.5rem; }
75
+
76
+ .bubble-chart { width: 100%; height: 400px; background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; overflow: hidden; }
77
+ `,St="/__dev/api";async function x(t,n="GET",o){const r={method:n,headers:{}};return o&&(r.headers["Content-Type"]="application/json",r.body=JSON.stringify(o)),(await fetch(St+t,r)).json()}function s(t){const n=document.createElement("span");return n.textContent=t,n.innerHTML}function It(t){t.innerHTML=`
78
+ <div class="dev-panel-header">
79
+ <h2>Routes <span id="routes-count" class="text-muted text-sm"></span></h2>
80
+ <button class="btn btn-sm" onclick="window.__loadRoutes()">Refresh</button>
81
+ </div>
82
+ <table>
83
+ <thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Handler</th></tr></thead>
84
+ <tbody id="routes-body"></tbody>
85
+ </table>
86
+ `,tt()}async function tt(){const t=await x("/routes"),n=document.getElementById("routes-count");n&&(n.textContent=`(${t.count})`);const o=document.getElementById("routes-body");o&&(o.innerHTML=(t.routes||[]).map(r=>`
87
+ <tr>
88
+ <td><span class="method method-${r.method.toLowerCase()}">${s(r.method)}</span></td>
89
+ <td class="text-mono"><a href="${s(r.path)}" target="_blank" style="color:inherit;text-decoration:underline dotted">${s(r.path)}</a></td>
90
+ <td>${r.auth_required?'<span class="badge badge-warn">auth</span>':'<span class="badge badge-success">open</span>'}</td>
91
+ <td class="text-sm text-muted">${s(r.handler||"")} <small>(${s(r.module||"")})</small></td>
92
+ </tr>
93
+ `).join(""))}window.__loadRoutes=tt;let H=[],z=[],S=JSON.parse(localStorage.getItem("tina4_query_history")||"[]");function Lt(t){t.innerHTML=`
94
+ <div class="dev-panel-header">
95
+ <h2>Database</h2>
96
+ <button class="btn btn-sm" onclick="window.__loadTables()">Refresh</button>
97
+ </div>
98
+ <div style="display:flex;gap:1rem;height:calc(100vh - 140px)">
99
+ <div style="width:200px;flex-shrink:0;overflow-y:auto;border-right:1px solid var(--border);padding-right:0.75rem">
100
+ <div style="font-weight:600;font-size:0.75rem;color:var(--muted);text-transform:uppercase;margin-bottom:0.5rem">Tables</div>
101
+ <div id="db-table-list"></div>
102
+ <div style="margin-top:1.5rem;border-top:1px solid var(--border);padding-top:0.75rem">
103
+ <div style="font-weight:600;font-size:0.75rem;color:var(--muted);text-transform:uppercase;margin-bottom:0.5rem">Seed Data</div>
104
+ <select id="db-seed-table" class="input" style="width:100%;margin-bottom:0.5rem">
105
+ <option value="">Pick table...</option>
106
+ </select>
107
+ <div class="flex gap-sm">
108
+ <input type="number" id="db-seed-count" class="input" value="10" style="width:60px">
109
+ <button class="btn btn-sm btn-primary" onclick="window.__seedTable()">Seed</button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ <div style="flex:1;display:flex;flex-direction:column;min-width:0">
114
+ <div class="flex gap-sm items-center" style="margin-bottom:0.5rem;flex-wrap:wrap">
115
+ <select id="db-type" class="input" style="width:80px">
116
+ <option value="sql">SQL</option>
117
+ <option value="graphql">GraphQL</option>
118
+ </select>
119
+ <span class="text-sm text-muted">Limit</span>
120
+ <select id="db-limit" class="input" style="width:60px">
121
+ <option value="20">20</option>
122
+ <option value="50">50</option>
123
+ <option value="100">100</option>
124
+ <option value="500">500</option>
125
+ </select>
126
+ <span class="text-sm text-muted">Offset</span>
127
+ <input type="number" id="db-offset" class="input" value="0" style="width:60px" min="0">
128
+ <button class="btn btn-primary" onclick="window.__runQuery()">Run</button>
129
+ <button class="btn" onclick="window.__copyCSV()">Copy CSV</button>
130
+ <button class="btn" onclick="window.__copyJSON()">Copy JSON</button>
131
+ <button class="btn" onclick="window.__showPaste()">Paste</button>
132
+ <span class="text-sm text-muted">Ctrl+Enter</span>
133
+ </div>
134
+ <div class="flex gap-sm items-center" style="margin-bottom:0.25rem">
135
+ <select id="db-history" class="input text-mono" style="flex:1" onchange="window.__loadHistory(this.value)">
136
+ <option value="">Query history...</option>
137
+ </select>
138
+ <button class="btn btn-sm" onclick="window.__clearHistory()" title="Clear history" style="height:30px">Clear</button>
139
+ </div>
140
+ <textarea id="db-query" class="input text-mono" style="width:100%;height:80px;resize:vertical" placeholder="SELECT * FROM users" onkeydown="if(event.ctrlKey&&event.key==='Enter')window.__runQuery()"></textarea>
141
+ <div id="db-result" style="flex:1;overflow:auto;margin-top:0.75rem"></div>
142
+ </div>
143
+ </div>
144
+ <div id="db-paste-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:1000;display:none;align-items:center;justify-content:center">
145
+ <div style="background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;padding:1.5rem;width:600px;max-height:80vh;overflow:auto">
146
+ <h3 style="margin-bottom:0.75rem;font-size:0.9rem">Paste Data</h3>
147
+ <p class="text-sm text-muted" style="margin-bottom:0.5rem">Paste CSV or JSON array. First row = column headers for CSV.</p>
148
+ <div class="flex gap-sm items-center" style="margin-bottom:0.5rem">
149
+ <select id="paste-table" class="input" style="flex:1"><option value="">Select existing table...</option></select>
150
+ <span class="text-sm text-muted">or</span>
151
+ <input type="text" id="paste-new-table" class="input" placeholder="New table name..." style="flex:1">
152
+ </div>
153
+ <textarea id="paste-data" class="input text-mono" style="width:100%;height:200px" placeholder='CSV data or JSON'></textarea>
154
+ <div class="flex gap-sm" style="margin-top:0.75rem;justify-content:flex-end">
155
+ <button class="btn" onclick="window.__hidePaste()">Cancel</button>
156
+ <button class="btn btn-primary" onclick="window.__doPaste()">Import</button>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ `,X(),J()}async function X(){const n=(await x("/tables")).tables||[],o=document.getElementById("db-table-list");o&&(o.innerHTML=n.length?n.map(l=>`<div style="padding:0.3rem 0.5rem;cursor:pointer;border-radius:0.25rem;font-size:0.8rem;font-family:monospace" class="db-table-item" onclick="window.__selectTable('${s(l)}')" onmouseover="this.style.background='var(--border)'" onmouseout="this.style.background=''">${s(l)}</div>`).join(""):'<div class="text-sm text-muted">No tables</div>');const r=document.getElementById("db-seed-table");r&&(r.innerHTML='<option value="">Pick table...</option>'+n.map(l=>`<option value="${s(l)}">${s(l)}</option>`).join(""));const a=document.getElementById("paste-table");a&&(a.innerHTML='<option value="">Select table...</option>'+n.map(l=>`<option value="${s(l)}">${s(l)}</option>`).join(""))}function Y(t){var o;(o=document.getElementById("db-limit"))!=null&&o.value;const n=document.getElementById("db-query");n&&(n.value=`SELECT * FROM ${t}`),document.querySelectorAll(".db-table-item").forEach(r=>{r.style.background=r.textContent===t?"var(--border)":""}),et()}function Ct(){var o;const t=document.getElementById("db-query"),n=((o=document.getElementById("db-limit"))==null?void 0:o.value)||"20";t!=null&&t.value&&(t.value=t.value.replace(/LIMIT\s+\d+/i,`LIMIT ${n}`))}function At(t){const n=t.trim();n&&(S=S.filter(o=>o!==n),S.unshift(n),S.length>50&&(S=S.slice(0,50)),localStorage.setItem("tina4_query_history",JSON.stringify(S)),J())}function J(){const t=document.getElementById("db-history");t&&(t.innerHTML='<option value="">Query history...</option>'+S.map((n,o)=>`<option value="${o}">${s(n.length>80?n.substring(0,80)+"...":n)}</option>`).join(""))}function Bt(t){const n=parseInt(t);if(isNaN(n)||!S[n])return;const o=document.getElementById("db-query");o&&(o.value=S[n]),document.getElementById("db-history").selectedIndex=0}function Ht(){S=[],localStorage.removeItem("tina4_query_history"),J()}async function et(){var a,l,h;const t=document.getElementById("db-query"),n=(a=t==null?void 0:t.value)==null?void 0:a.trim();if(!n)return;At(n);const o=document.getElementById("db-result"),r=((l=document.getElementById("db-type"))==null?void 0:l.value)||"sql";o&&(o.innerHTML='<p class="text-muted">Running...</p>');try{const w=parseInt(((h=document.getElementById("db-limit"))==null?void 0:h.value)||"20"),v=await x("/query","POST",{query:n,type:r,limit:w});if(v.error){o&&(o.innerHTML=`<p style="color:var(--danger)">${s(v.error)}</p>`);return}v.rows&&v.rows.length>0?(z=Object.keys(v.rows[0]),H=v.rows,o&&(o.innerHTML=`<p class="text-sm text-muted" style="margin-bottom:0.5rem">${v.count??v.rows.length} rows</p>
161
+ <div style="overflow-x:auto"><table><thead><tr>${z.map(_=>`<th>${s(_)}</th>`).join("")}</tr></thead>
162
+ <tbody>${v.rows.map(_=>`<tr>${z.map(b=>`<td class="text-sm">${s(String(_[b]??""))}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`)):v.affected!==void 0?(o&&(o.innerHTML=`<p class="text-muted">${v.affected} rows affected. ${v.success?"Success.":""}</p>`),H=[],z=[]):(o&&(o.innerHTML='<p class="text-muted">No results</p>'),H=[],z=[])}catch(w){o&&(o.innerHTML=`<p style="color:var(--danger)">${s(w.message)}</p>`)}}function zt(){if(!H.length)return;const t=z.join(","),n=H.map(o=>z.map(r=>{const a=String(o[r]??"");return a.includes(",")||a.includes('"')?`"${a.replace(/"/g,'""')}"`:a}).join(","));navigator.clipboard.writeText([t,...n].join(`
163
+ `))}function Pt(){H.length&&navigator.clipboard.writeText(JSON.stringify(H,null,2))}function Ot(){const t=document.getElementById("db-paste-modal");t&&(t.style.display="flex")}function nt(){const t=document.getElementById("db-paste-modal");t&&(t.style.display="none")}async function jt(){var a,l,h,w,v;const t=(a=document.getElementById("paste-table"))==null?void 0:a.value,n=(h=(l=document.getElementById("paste-new-table"))==null?void 0:l.value)==null?void 0:h.trim(),o=n||t,r=(v=(w=document.getElementById("paste-data"))==null?void 0:w.value)==null?void 0:v.trim();if(!o||!r){alert("Select a table or enter a new table name, and paste data.");return}try{let _;try{_=JSON.parse(r),Array.isArray(_)||(_=[_])}catch{const M=r.split(`
164
+ `).map($=>$.trim()).filter(Boolean);if(M.length<2){alert("CSV needs at least a header row and one data row.");return}const C=M[0].split(",").map($=>$.trim().replace(/[^a-zA-Z0-9_]/g,""));_=M.slice(1).map($=>{const I=$.split(",").map(k=>k.trim()),A={};return C.forEach((k,P)=>{A[k]=I[P]??""}),A})}if(!_.length){alert("No data rows found.");return}if(n){const C=["id INTEGER PRIMARY KEY AUTOINCREMENT",...Object.keys(_[0]).filter(I=>I.toLowerCase()!=="id").map(I=>`"${I}" TEXT`)],$=await x("/query","POST",{query:`CREATE TABLE IF NOT EXISTS "${n}" (${C.join(", ")})`,type:"sql"});if($.error){alert("Create table failed: "+$.error);return}}let b=0;for(const M of _){const C=n?Object.keys(M).filter(k=>k.toLowerCase()!=="id"):Object.keys(M),$=C.map(k=>`"${k}"`).join(","),I=C.map(k=>`'${String(M[k]).replace(/'/g,"''")}'`).join(","),A=await x("/query","POST",{query:`INSERT INTO "${o}" (${$}) VALUES (${I})`,type:"sql"});if(A.error){alert(`Row ${b+1} failed: ${A.error}`);break}b++}document.getElementById("paste-data").value="",document.getElementById("paste-new-table").value="",document.getElementById("paste-table").selectedIndex=0,nt(),X(),b>0&&Y(o)}catch(_){alert("Import error: "+_.message)}}async function Nt(){var o,r;const t=(o=document.getElementById("db-seed-table"))==null?void 0:o.value,n=parseInt(((r=document.getElementById("db-seed-count"))==null?void 0:r.value)||"10");if(t)try{const a=await x("/seed","POST",{table:t,count:n});a.error?alert(a.error):Y(t)}catch(a){alert("Seed error: "+a.message)}}window.__loadTables=X,window.__selectTable=Y,window.__updateLimit=Ct,window.__runQuery=et,window.__copyCSV=zt,window.__copyJSON=Pt,window.__showPaste=Ot,window.__hidePaste=nt,window.__doPaste=jt,window.__seedTable=Nt,window.__loadHistory=Bt,window.__clearHistory=Ht;function Rt(t){t.innerHTML=`
165
+ <div class="dev-panel-header">
166
+ <h2>Errors <span id="errors-count" class="text-muted text-sm"></span></h2>
167
+ <div class="flex gap-sm">
168
+ <button class="btn btn-sm" onclick="window.__loadErrors()">Refresh</button>
169
+ <button class="btn btn-sm btn-danger" onclick="window.__clearErrors()">Clear All</button>
170
+ </div>
171
+ </div>
172
+ <div id="errors-body"></div>
173
+ `,V()}async function V(){const t=await x("/broken"),n=document.getElementById("errors-count"),o=document.getElementById("errors-body");if(!o)return;const r=t.errors||[];if(n&&(n.textContent=`(${r.length})`),!r.length){o.innerHTML='<div class="empty-state">No errors</div>';return}o.innerHTML=r.map((a,l)=>`
174
+ <div style="background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;padding:0.75rem;margin-bottom:0.75rem">
175
+ <div class="flex items-center" style="justify-content:space-between">
176
+ <div>
177
+ <span class="badge badge-danger">UNRESOLVED</span>
178
+ <strong style="margin-left:0.5rem;font-size:0.85rem">${s(a.error||a.message||"Unknown error")}</strong>
179
+ </div>
180
+ <div class="flex gap-sm">
181
+ <button class="btn btn-sm" onclick="window.__resolveError('${s(a.id||String(l))}')">Resolve</button>
182
+ <button class="btn btn-sm btn-primary" onclick="window.__askAboutError(${l})">Ask Tina4</button>
183
+ </div>
184
+ </div>
185
+ ${a.traceback?`<div class="error-trace">${s(a.traceback)}</div>`:""}
186
+ <div class="text-sm text-muted" style="margin-top:0.5rem">${s(a.timestamp||"")}</div>
187
+ </div>
188
+ `).join(""),window.__errorData=r}async function qt(t){await x("/broken/resolve","POST",{id:t}),V()}async function Ft(){await x("/broken/clear","POST"),V()}function Dt(t){const o=(window.__errorData||[])[t];if(!o)return;const r=document.querySelector('[data-tab="chat"]');r&&r.click(),setTimeout(()=>{const a=document.getElementById("chat-input");a&&(a.value=`I have this error: ${o.error||o.message}
189
+
190
+ ${o.traceback||""}`,a.focus())},100)}window.__loadErrors=V,window.__clearErrors=Ft,window.__resolveError=qt,window.__askAboutError=Dt;function Vt(t){t.innerHTML=`
191
+ <div class="dev-panel-header">
192
+ <h2>System</h2>
193
+ <button class="btn btn-sm" onclick="window.__loadSystem()">Refresh</button>
194
+ </div>
195
+ <div id="system-grid" class="metric-grid"></div>
196
+ `,ot()}async function ot(){const t=await x("/system"),n=document.getElementById("system-grid");if(!n)return;const o=[{label:"Framework",value:t.framework||"Tina4"},{label:"Version",value:t.version||"?"},{label:"Runtime",value:t.runtime||t.python_version||t.php_version||t.ruby_version||t.node_version||"?"},{label:"Database",value:t.database||t.db_type||"none"},{label:"Uptime",value:t.uptime||"?"},{label:"Memory",value:t.memory||"?"},{label:"Platform",value:t.platform||"?"},{label:"Routes",value:String(t.route_count??t.routes??"?")},{label:"Debug",value:t.debug?"ON":"OFF"}];n.innerHTML=o.map(r=>`
197
+ <div class="metric-card">
198
+ <div class="label">${s(r.label)}</div>
199
+ <div class="value" style="font-size:1.1rem">${s(r.value)}</div>
200
+ </div>
201
+ `).join("")}window.__loadSystem=ot;function Qt(t){t.innerHTML=`
202
+ <div class="dev-panel-header">
203
+ <h2>Code Metrics</h2>
204
+ <div class="flex gap-sm">
205
+ <button class="btn" onclick="window.__loadQuickMetrics()">Quick Scan</button>
206
+ <button class="btn btn-primary" onclick="window.__loadFullMetrics()">Full Analysis</button>
207
+ </div>
208
+ </div>
209
+ <div id="metrics-quick" class="metric-grid"></div>
210
+ <div id="metrics-scan-info" class="text-sm text-muted" style="margin:0.5rem 0"></div>
211
+ <div id="metrics-chart" style="display:none;margin:1rem 0"></div>
212
+ <div id="metrics-complex" style="margin-top:1rem"></div>
213
+ <div id="metrics-detail" style="margin-top:1rem"></div>
214
+ `,rt()}async function Xt(){const t=await x("/metrics"),n=document.getElementById("metrics-quick");!n||t.error||(n.innerHTML=[y("Files",t.file_count),y("Lines of Code",t.total_loc),y("Blank Lines",t.total_blank),y("Comments",t.total_comment),y("Classes",t.classes),y("Functions",t.functions),y("Routes",t.route_count),y("ORM Models",t.orm_count),y("Templates",t.template_count),y("Migrations",t.migration_count),y("Avg File Size",(t.avg_file_size??0)+" LOC")].join(""))}async function rt(){var l;const t=document.getElementById("metrics-chart"),n=document.getElementById("metrics-complex"),o=document.getElementById("metrics-scan-info");t&&(t.style.display="block",t.innerHTML='<p class="text-muted">Analyzing...</p>');const r=await x("/metrics/full");if(r.error||!r.file_metrics){t&&(t.innerHTML=`<p style="color:var(--danger)">${s(r.error||"No data")}</p>`);return}o&&(o.textContent=`${r.files_analyzed} files analyzed | ${r.total_functions} functions | Mode: ${r.scan_mode||"project"}`);const a=document.getElementById("metrics-quick");a&&(a.innerHTML=[y("Files Analyzed",r.files_analyzed),y("Total Functions",r.total_functions),y("Avg Complexity",r.avg_complexity),y("Avg Maintainability",r.avg_maintainability),y("Scan Mode",r.scan_mode||"project")].join("")),t&&r.file_metrics.length>0?Yt(r.file_metrics,t,r.dependency_graph||{}):t&&(t.innerHTML='<p class="text-muted">No files to visualize</p>'),n&&((l=r.most_complex_functions)!=null&&l.length)&&(n.innerHTML=`
215
+ <h3 style="font-size:0.85rem;margin-bottom:0.5rem">Most Complex Functions</h3>
216
+ <table>
217
+ <thead><tr><th>Function</th><th>File</th><th>Line</th><th>Complexity</th><th>LOC</th></tr></thead>
218
+ <tbody>${r.most_complex_functions.slice(0,15).map(h=>`
219
+ <tr>
220
+ <td class="text-mono">${s(h.name)}</td>
221
+ <td class="text-sm text-muted" style="cursor:pointer;text-decoration:underline dotted" onclick="window.__drillDown('${s(h.file)}')">${s(h.file)}</td>
222
+ <td>${h.line}</td>
223
+ <td><span class="${h.complexity>10?"badge badge-danger":h.complexity>5?"badge badge-warn":"badge badge-success"}">${h.complexity}</span></td>
224
+ <td>${h.loc}</td>
225
+ </tr>`).join("")}
226
+ </tbody>
227
+ </table>
228
+ `)}function Yt(t,n,o){var ht,vt,ft,xt,wt,_t;n.clientWidth;const r=450,a=Math.max(...t.map(e=>e.loc||1)),l=18,h=50,w=1e3,v=1e3,b=[...t].sort((e,i)=>{const c=(e.avg_complexity??0)*2+(e.loc||0);return(i.avg_complexity??0)*2+(i.loc||0)-c}).map(e=>({...e,r:Math.max(l,Math.min(h,Math.sqrt((e.loc||1)/a)*h)),x:w,y:v}));for(let e=0;e<b.length;e++){if(e===0)continue;let i=0,c=0,m=!1;for(;!m;){const d=w+Math.cos(i)*c,g=v+Math.sin(i)*c;let p=!1;for(let f=0;f<e;f++){const L=d-b[f].x,D=g-b[f].y;if(Math.sqrt(L*L+D*D)<b[e].r+b[f].r+4){p=!0;break}}p||(b[e].x=d,b[e].y=g,m=!0),i+=.3,c+=.5}}let M=1/0,C=-1/0,$=1/0,I=-1/0;for(const e of b)M=Math.min(M,e.x-e.r-15),C=Math.max(C,e.x+e.r+15),$=Math.min($,e.y-e.r-15),I=Math.max(I,e.y+e.r+25);const A=30,k=M-A,P=$-A,O=C-M+A*2,j=I-$+A*2,B=Math.max(20,Math.round(Math.max(O,j)/20));n.innerHTML=`
229
+ <div style="position:relative;display:flex;gap:0">
230
+ <div style="flex:1;position:relative">
231
+ <div style="position:absolute;top:8px;left:8px;z-index:2;display:flex;gap:4px;flex-direction:column">
232
+ <button class="btn btn-sm" id="metrics-zoom-in" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">+</button>
233
+ <button class="btn btn-sm" id="metrics-zoom-out" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">&minus;</button>
234
+ <button class="btn btn-sm" id="metrics-zoom-fit" style="width:28px;height:28px;padding:0;font-size:10px;font-weight:700;line-height:1">Fit</button>
235
+ </div>
236
+ <svg id="metrics-svg" width="100%" height="${r}" viewBox="${k} ${P} ${O} ${j}" style="background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;cursor:grab"></svg>
237
+ </div>
238
+ <div id="metrics-hover-panel" style="width:200px;flex-shrink:0;background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;padding:0.75rem;font-size:0.75rem;margin-left:0.5rem;overflow-y:auto;height:${r}px">
239
+ <div class="text-muted" style="text-align:center;padding-top:2rem">Hover a bubble<br>to see stats</div>
240
+ </div>
241
+ </div>
242
+ `;const E=document.getElementById("metrics-svg");if(!E)return;const W={};for(const e of b)e.path&&(W[e.path]={x:e.x,y:e.y,r:e.r});let T="";const mt=Math.floor((k-O)/B)*B,ut=Math.ceil((k+O*3)/B)*B,pt=Math.floor((P-j)/B)*B,bt=Math.ceil((P+j*3)/B)*B;T+='<g class="metrics-grid">';for(let e=mt;e<=ut;e+=B)T+=`<line x1="${e}" y1="${pt}" x2="${e}" y2="${bt}" stroke="var(--border)" stroke-width="0.5" stroke-opacity="0.4" />`;for(let e=pt;e<=bt;e+=B)T+=`<line x1="${mt}" y1="${e}" x2="${ut}" y2="${e}" stroke="var(--border)" stroke-width="0.5" stroke-opacity="0.4" />`;T+="</g>",T+='<g class="dep-lines">';for(const[e,i]of Object.entries(o)){const c=W[e];if(c)for(const m of i){const d=Object.entries(W).find(([g])=>{var f;const p=((f=g.split("/").pop())==null?void 0:f.replace(/\.\w+$/,""))||"";return g===m||p===m||g.endsWith("/"+m)||g.endsWith("/"+m+".py")});if(d){const[,g]=d;T+=`<line x1="${c.x}" y1="${c.y}" x2="${g.x}" y2="${g.y}" stroke="var(--info)" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="4 3" />`}}}T+="</g>";for(const e of b){const i=e.maintainability??50,m=`hsl(${Math.min(120,Math.max(0,i*1.2))}, 80%, 45%)`,d=((ht=e.path)==null?void 0:ht.split("/").pop())||"?",g=e.has_tests===!0,p=e.dep_count??0;if(T+=`<circle cx="${e.x}" cy="${e.y}" r="${e.r}" fill="${m}" fill-opacity="0.6" stroke="${m}" stroke-width="1.5" style="cursor:pointer" data-drill="${s(e.path)}" />`,T+=`<title>${s(e.path)}
243
+ LOC: ${e.loc} | CC: ${e.avg_complexity} | MI: ${i}${g?" | Tested":""}${p>0?" | Deps: "+p:""}</title>`,e.r>15){const f=d.length>12?d.substring(0,10)+"..":d;T+=`<text x="${e.x}" y="${e.y+2}" text-anchor="middle" fill="white" font-size="8" font-weight="600" style="pointer-events:none" data-for="${s(e.path)}" data-role="label">${s(f)}</text>`}if(g){const f=e.x+e.r*.6,L=e.y-e.r*.6;T+=`<circle cx="${f}" cy="${L}" r="7" fill="var(--success)" stroke="var(--surface)" stroke-width="1" data-for="${s(e.path)}" data-role="t-circle" />`,T+=`<text x="${f}" y="${L+3}" text-anchor="middle" fill="white" font-size="7" font-weight="700" style="pointer-events:none" data-for="${s(e.path)}" data-role="t-text">T</text>`}if(p>0){const f=e.x-e.r*.6,L=e.y-e.r*.6;T+=`<circle cx="${f}" cy="${L}" r="7" fill="var(--info)" stroke="var(--surface)" stroke-width="1" data-for="${s(e.path)}" data-role="d-circle" />`,T+=`<text x="${f}" y="${L+3}" text-anchor="middle" fill="white" font-size="7" font-weight="700" style="pointer-events:none" data-for="${s(e.path)}" data-role="d-text">D</text>`}}E.innerHTML=T;let q=!1,Q=!1,N=null,F={x:0,y:0,vbX:0,vbY:0},u={x:k,y:P,w:O,h:j};const ee={x:k,y:P,w:O,h:j},gt=4,ne=document.getElementById("metrics-hover-panel");function K(){E.setAttribute("viewBox",`${u.x} ${u.y} ${u.w} ${u.h}`)}function yt(e){const i=u.x+u.w/2,c=u.y+u.h/2;u.w*=e,u.h*=e,u.x=i-u.w/2,u.y=c-u.h/2,K()}function oe(e,i){const c=E.getBoundingClientRect();return{x:u.x+(e-c.left)/c.width*u.w,y:u.y+(i-c.top)/c.height*u.h}}function re(){E.querySelectorAll(".dep-lines line").forEach(i=>i.remove());const e=E.querySelector(".dep-lines");if(e)for(const[i,c]of Object.entries(o)){const m=b.find(d=>d.path===i);if(m)for(const d of c){const g=b.find(p=>{var L,D,$t,kt;const f=((D=(L=p.path)==null?void 0:L.split("/").pop())==null?void 0:D.replace(/\.\w+$/,""))||"";return p.path===d||f===d||(($t=p.path)==null?void 0:$t.endsWith("/"+d))||((kt=p.path)==null?void 0:kt.endsWith("/"+d+".py"))});if(g){const p=document.createElementNS("http://www.w3.org/2000/svg","line");p.setAttribute("x1",String(m.x)),p.setAttribute("y1",String(m.y)),p.setAttribute("x2",String(g.x)),p.setAttribute("y2",String(g.y)),p.setAttribute("stroke","var(--info)"),p.setAttribute("stroke-width","1"),p.setAttribute("stroke-opacity","0.3"),p.setAttribute("stroke-dasharray","4 3"),e.appendChild(p)}}}E.querySelectorAll("[data-drill]").forEach(i=>{const c=i.getAttribute("data-drill"),m=b.find(d=>d.path===c);m&&(i.setAttribute("cx",String(m.x)),i.setAttribute("cy",String(m.y)))}),E.querySelectorAll("[data-for]").forEach(i=>{const c=i.getAttribute("data-for"),m=i.getAttribute("data-role"),d=b.find(g=>g.path===c);d&&(m==="label"?(i.setAttribute("x",String(d.x)),i.setAttribute("y",String(d.y+2))):m==="t-circle"?(i.setAttribute("cx",String(d.x+d.r*.6)),i.setAttribute("cy",String(d.y-d.r*.6))):m==="t-text"?(i.setAttribute("x",String(d.x+d.r*.6)),i.setAttribute("y",String(d.y-d.r*.6+3))):m==="d-circle"?(i.setAttribute("cx",String(d.x-d.r*.6)),i.setAttribute("cy",String(d.y-d.r*.6))):m==="d-text"&&(i.setAttribute("x",String(d.x-d.r*.6)),i.setAttribute("y",String(d.y-d.r*.6+3))))})}function ie(e){const i=e.maintainability??0,m=`hsl(${Math.min(120,Math.max(0,i*1.2))}, 80%, 45%)`;ne.innerHTML=`
244
+ <div style="font-weight:700;font-size:0.85rem;margin-bottom:0.5rem;word-break:break-all">${s(e.path||"?")}</div>
245
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:0.5rem">
246
+ <div><span class="text-muted">LOC</span><br><strong>${e.loc??0}</strong></div>
247
+ <div><span class="text-muted">Lines</span><br><strong>${e.total_lines??e.loc??0}</strong></div>
248
+ <div><span class="text-muted">Complexity</span><br><strong>${e.avg_complexity??0}</strong></div>
249
+ <div><span class="text-muted">MI</span><br><strong style="color:${m}">${i}</strong></div>
250
+ <div><span class="text-muted">Functions</span><br><strong>${e.function_count??0}</strong></div>
251
+ <div><span class="text-muted">Deps</span><br><strong>${e.dep_count??0}</strong></div>
252
+ </div>
253
+ <div style="margin-bottom:0.25rem">${e.has_tests?'<span class="badge badge-success">Tested</span>':'<span class="badge badge-muted">No tests</span>'}</div>
254
+ ${(e.dep_count??0)>0?'<div><span class="badge badge-info">'+e.dep_count+" dependencies</span></div>":""}
255
+ <div style="margin-top:0.75rem;font-size:0.7rem;color:var(--muted)">Click to drill down</div>
256
+ `}E.querySelectorAll("[data-drill]").forEach(e=>{e.addEventListener("mouseenter",()=>{const i=e.getAttribute("data-drill"),c=b.find(m=>m.path===i);c&&ie(c)}),e.addEventListener("click",i=>{if(q)return;i.stopPropagation();const c=e.getAttribute("data-drill");c&&it(c)})}),E.addEventListener("mousedown",e=>{var m;if(e.button!==0)return;q=!1;const i=e.target,c=(m=i==null?void 0:i.getAttribute)==null?void 0:m.call(i,"data-drill");if(c){const d=b.find(g=>g.path===c);if(d){N=d,E.style.cursor="move",e.preventDefault();return}}Q=!0,F={x:e.clientX,y:e.clientY,vbX:u.x,vbY:u.y}}),window.addEventListener("mousemove",e=>{if(N){q=!0;const d=oe(e.clientX,e.clientY);N.x=d.x,N.y=d.y,re();return}if(!Q)return;const i=e.clientX-F.x,c=e.clientY-F.y;if(!q&&Math.abs(i)<gt&&Math.abs(c)<gt)return;q=!0,E.style.cursor="grabbing";const m=E.getBoundingClientRect();u.x=F.vbX-i/m.width*u.w,u.y=F.vbY-c/m.height*u.h,K()}),window.addEventListener("mouseup",()=>{N&&(N=null,E.style.cursor="grab"),Q&&(Q=!1,E.style.cursor="grab")}),(vt=document.getElementById("metrics-zoom-in"))==null||vt.addEventListener("click",()=>yt(.7)),(ft=document.getElementById("metrics-zoom-out"))==null||ft.addEventListener("click",()=>yt(1.4)),(xt=document.getElementById("metrics-zoom-fit"))==null||xt.addEventListener("click",()=>{u={...ee},K()});const G=document.createElement("div");G.style.cssText="position:absolute;bottom:8px;left:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:11px;line-height:1.6;opacity:0.9;z-index:2",G.innerHTML=`
257
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:hsl(0,80%,45%);vertical-align:middle"></span> Low MI &nbsp;
258
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:hsl(60,80%,45%);vertical-align:middle"></span> Med &nbsp;
259
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:hsl(120,80%,45%);vertical-align:middle"></span> High MI &nbsp;
260
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--success);vertical-align:middle"></span> T &nbsp;
261
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--info);vertical-align:middle"></span> D &nbsp;
262
+ <span style="color:var(--info)">---</span> Dep
263
+ `,(_t=(wt=n.querySelector("div > div:first-child"))==null?void 0:wt.parentElement)==null||_t.appendChild(G)}async function it(t){const n=document.getElementById("metrics-detail");if(!n)return;n.innerHTML='<p class="text-muted">Loading file analysis...</p>';const o=await x("/metrics/file?path="+encodeURIComponent(t));if(o.error){n.innerHTML=`<p style="color:var(--danger)">${s(o.error)}</p>`;return}const r=o.functions||[],a=o.warnings||[];n.innerHTML=`
264
+ <div style="background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;padding:1rem">
265
+ <div class="flex items-center" style="justify-content:space-between;margin-bottom:0.75rem">
266
+ <h3 style="font-size:0.9rem">${s(o.path)}</h3>
267
+ <button class="btn btn-sm" onclick="document.getElementById('metrics-detail').innerHTML=''">Close</button>
268
+ </div>
269
+ <div class="metric-grid" style="margin-bottom:0.75rem">
270
+ ${y("LOC",o.loc)}
271
+ ${y("Total Lines",o.total_lines)}
272
+ ${y("Classes",o.classes)}
273
+ ${y("Functions",r.length)}
274
+ </div>
275
+ ${r.length?`
276
+ <table>
277
+ <thead><tr><th>Function</th><th>Line</th><th>Complexity</th><th>LOC</th><th>Args</th></tr></thead>
278
+ <tbody>${r.map(l=>`
279
+ <tr>
280
+ <td class="text-mono">${s(l.name)}</td>
281
+ <td>${l.line}</td>
282
+ <td><span class="${l.complexity>10?"badge badge-danger":l.complexity>5?"badge badge-warn":"badge badge-success"}">${l.complexity}</span></td>
283
+ <td>${l.loc}</td>
284
+ <td class="text-sm text-muted">${(l.args||[]).join(", ")}</td>
285
+ </tr>`).join("")}
286
+ </tbody>
287
+ </table>
288
+ `:'<p class="text-muted">No functions</p>'}
289
+ ${a.length?`
290
+ <div style="margin-top:0.75rem">
291
+ <h4 style="font-size:0.8rem;color:var(--warn);margin-bottom:0.25rem">Warnings</h4>
292
+ ${a.map(l=>`<p class="text-sm" style="color:var(--warn)">Line ${l.line}: ${s(l.message)}</p>`).join("")}
293
+ </div>
294
+ `:""}
295
+ </div>
296
+ `}function y(t,n){return`<div class="metric-card"><div class="label">${s(t)}</div><div class="value">${s(String(n??0))}</div></div>`}window.__loadQuickMetrics=Xt,window.__loadFullMetrics=rt,window.__drillDown=it;let at="anthropic",R="";function Jt(t){t.innerHTML=`
297
+ <div class="dev-panel-header">
298
+ <h2>Code With Me</h2>
299
+ <div class="flex gap-sm items-center">
300
+ <select id="ai-provider" class="input" style="width:120px" onchange="window.__setProvider(this.value)">
301
+ <option value="anthropic">Claude</option>
302
+ <option value="openai">OpenAI</option>
303
+ <option value="ollama">Ollama</option>
304
+ </select>
305
+ <input type="password" id="ai-key" class="input" placeholder="API key..." style="width:200px">
306
+ <button class="btn btn-sm btn-primary" onclick="window.__setAiKey()">Set</button>
307
+ <span class="text-sm text-muted" id="ai-status">${R?"Key set":"No key"}</span>
308
+ </div>
309
+ </div>
310
+ <div class="chat-container">
311
+ <div class="chat-messages" id="chat-messages">
312
+ <div class="chat-msg chat-bot">Hi! I'm Tina4. Ask me to build routes, templates, models — or ask questions about your project. I can read and write files directly.</div>
313
+ </div>
314
+ <div class="chat-input-row">
315
+ <input type="text" id="chat-input" class="input" placeholder="Ask Tina4 to build something..." onkeydown="if(event.key==='Enter')window.__sendChat()" style="flex:1">
316
+ <button class="btn btn-primary" onclick="window.__sendChat()">Send</button>
317
+ <button class="btn btn-sm" onclick="window.__undoChat()" title="Undo last file change">Undo</button>
318
+ </div>
319
+ </div>
320
+ `}async function Ut(){var a;const t=document.getElementById("chat-input"),n=(a=t==null?void 0:t.value)==null?void 0:a.trim();if(!n)return;t.value="";const o=document.getElementById("chat-messages");if(!o)return;o.innerHTML+=`<div class="chat-msg chat-user">${s(n)}</div>`,o.innerHTML+='<div class="chat-msg chat-bot" id="chat-loading" style="color:var(--muted)">Thinking...</div>',o.scrollTop=o.scrollHeight;const r={message:n,provider:at};R&&(r.api_key=R);try{const l=await x("/chat","POST",r),h=document.getElementById("chat-loading");h&&h.remove();let w=Zt(l.reply||"No response");l.files_changed&&l.files_changed.length>0&&(w+='<div style="margin-top:0.5rem;padding:0.5rem;background:var(--bg);border-radius:0.375rem;border:1px solid var(--border)">',w+='<div class="text-sm" style="color:var(--success);font-weight:600;margin-bottom:0.25rem">Files changed:</div>',l.files_changed.forEach(v=>{w+=`<div class="text-sm text-mono">${s(v)}</div>`}),w+="</div>"),o.innerHTML+=`<div class="chat-msg chat-bot">${w}</div>`,o.innerHTML+=`<div class="text-sm text-muted" style="text-align:right;margin-bottom:0.25rem">${s(l.source||"")}</div>`,o.scrollTop=o.scrollHeight}catch{const l=document.getElementById("chat-loading");l&&(l.textContent="Error connecting",l.id="")}}async function Wt(){try{const t=await x("/chat/undo","POST"),n=document.getElementById("chat-messages");n&&(n.innerHTML+=`<div class="chat-msg chat-bot" style="color:var(--warn)">${s(t.message||"Undo complete")}</div>`,n.scrollTop=n.scrollHeight)}catch{alert("Nothing to undo")}}function Kt(){const t=document.getElementById("ai-key");R=(t==null?void 0:t.value)||"";const n=document.getElementById("ai-status");n&&(n.textContent=R?"Key set":"No key")}function Gt(t){at=t}function Zt(t){return t.replace(/```(\w*)\n([\s\S]*?)```/g,'<pre style="background:var(--bg);padding:0.5rem;border-radius:0.375rem;overflow-x:auto;margin:0.5rem 0;font-size:0.8rem"><code>$2</code></pre>').replace(/`([^`]+)`/g,'<code style="background:var(--bg);padding:0.1rem 0.25rem;border-radius:0.2rem;font-size:0.8em">$1</code>').replace(/\n/g,"<br>")}window.__sendChat=Ut,window.__undoChat=Wt,window.__setAiKey=Kt,window.__setProvider=Gt;const st=document.createElement("style");st.textContent=Mt,document.head.appendChild(st);const dt=Et();Tt(dt);const lt=[{id:"chat",label:"Code With Me",render:Jt},{id:"routes",label:"Routes",render:It},{id:"database",label:"Database",render:Lt},{id:"errors",label:"Errors",render:Rt},{id:"metrics",label:"Metrics",render:Qt},{id:"system",label:"System",render:Vt}];let U="chat";function te(){const t=document.getElementById("app");if(!t)return;t.innerHTML=`
321
+ <div class="dev-admin">
322
+ <div class="dev-header">
323
+ <h1><span>Tina4</span> Dev Admin</h1>
324
+ <span class="text-sm text-muted">${dt.name} &bull; v3.10</span>
325
+ </div>
326
+ <div class="dev-tabs" id="tab-bar"></div>
327
+ <div class="dev-content" id="tab-content"></div>
328
+ </div>
329
+ `;const n=document.getElementById("tab-bar");n.innerHTML=lt.map(o=>`<button class="dev-tab ${o.id===U?"active":""}" data-tab="${o.id}" onclick="window.__switchTab('${o.id}')">${o.label}</button>`).join(""),ct(U)}function ct(t){U=t,document.querySelectorAll(".dev-tab").forEach(a=>{a.classList.toggle("active",a.dataset.tab===t)});const n=document.getElementById("tab-content");if(!n)return;const o=document.createElement("div");o.className="dev-panel active",n.innerHTML="",n.appendChild(o);const r=lt.find(a=>a.id===t);r&&r.render(o)}window.__switchTab=ct,te()})();
File without changes