AstrBot 4.11.3__py3-none-any.whl → 4.12.0__py3-none-any.whl

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 (41) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/runners/tool_loop_agent_runner.py +10 -8
  3. astrbot/core/config/default.py +66 -13
  4. astrbot/core/db/__init__.py +84 -2
  5. astrbot/core/db/po.py +65 -0
  6. astrbot/core/db/sqlite.py +225 -4
  7. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +103 -49
  8. astrbot/core/pipeline/process_stage/utils.py +40 -0
  9. astrbot/core/platform/sources/discord/discord_platform_adapter.py +2 -0
  10. astrbot/core/platform/sources/telegram/tg_adapter.py +2 -0
  11. astrbot/core/platform/sources/webchat/webchat_adapter.py +3 -2
  12. astrbot/core/platform/sources/webchat/webchat_event.py +17 -4
  13. astrbot/core/provider/sources/anthropic_source.py +44 -0
  14. astrbot/core/sandbox/booters/base.py +31 -0
  15. astrbot/core/sandbox/booters/boxlite.py +186 -0
  16. astrbot/core/sandbox/booters/shipyard.py +67 -0
  17. astrbot/core/sandbox/olayer/__init__.py +5 -0
  18. astrbot/core/sandbox/olayer/filesystem.py +33 -0
  19. astrbot/core/sandbox/olayer/python.py +19 -0
  20. astrbot/core/sandbox/olayer/shell.py +21 -0
  21. astrbot/core/sandbox/sandbox_client.py +52 -0
  22. astrbot/core/sandbox/tools/__init__.py +10 -0
  23. astrbot/core/sandbox/tools/fs.py +188 -0
  24. astrbot/core/sandbox/tools/python.py +74 -0
  25. astrbot/core/sandbox/tools/shell.py +55 -0
  26. astrbot/core/star/context.py +162 -44
  27. astrbot/core/utils/metrics.py +2 -0
  28. astrbot/dashboard/routes/__init__.py +2 -0
  29. astrbot/dashboard/routes/chat.py +40 -12
  30. astrbot/dashboard/routes/chatui_project.py +245 -0
  31. astrbot/dashboard/routes/session_management.py +545 -0
  32. astrbot/dashboard/server.py +1 -0
  33. {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/METADATA +2 -3
  34. {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/RECORD +37 -28
  35. astrbot/builtin_stars/python_interpreter/main.py +0 -536
  36. astrbot/builtin_stars/python_interpreter/metadata.yaml +0 -4
  37. astrbot/builtin_stars/python_interpreter/requirements.txt +0 -1
  38. astrbot/builtin_stars/python_interpreter/shared/api.py +0 -22
  39. {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/WHEEL +0 -0
  40. {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/entry_points.txt +0 -0
  41. {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/licenses/LICENSE +0 -0
astrbot/cli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "4.11.3"
1
+ __version__ = "4.12.0"
@@ -227,7 +227,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
227
227
  encrypted=llm_resp.reasoning_signature,
228
228
  )
229
229
  )
230
- parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
230
+ if llm_resp.completion_text:
231
+ parts.append(TextPart(text=llm_resp.completion_text))
231
232
  self.run_context.messages.append(Message(role="assistant", content=parts))
232
233
 
233
234
  # call the on_agent_done hook
@@ -277,7 +278,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
277
278
  encrypted=llm_resp.reasoning_signature,
278
279
  )
279
280
  )
280
- parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
281
+ if llm_resp.completion_text:
282
+ parts.append(TextPart(text=llm_resp.completion_text))
281
283
  tool_calls_result = ToolCallsResult(
282
284
  tool_calls_info=AssistantMessageSegment(
283
285
  tool_calls=llm_resp.to_openai_to_calls_model(),
@@ -361,7 +363,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
361
363
  ToolCallMessageSegment(
362
364
  role="tool",
363
365
  tool_call_id=func_tool_id,
364
- content=f"error: 未找到工具 {func_tool_name}",
366
+ content=f"error: Tool {func_tool_name} not found.",
365
367
  ),
366
368
  )
367
369
  continue
@@ -427,7 +429,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
427
429
  ToolCallMessageSegment(
428
430
  role="tool",
429
431
  tool_call_id=func_tool_id,
430
- content="返回了图片(已直接发送给用户)",
432
+ content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
431
433
  ),
432
434
  )
433
435
  yield MessageChain(type="tool_direct_result").base64_image(
@@ -452,7 +454,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
452
454
  ToolCallMessageSegment(
453
455
  role="tool",
454
456
  tool_call_id=func_tool_id,
455
- content="返回了图片(已直接发送给用户)",
457
+ content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
456
458
  ),
457
459
  )
458
460
  yield MessageChain(
@@ -463,7 +465,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
463
465
  ToolCallMessageSegment(
464
466
  role="tool",
465
467
  tool_call_id=func_tool_id,
466
- content="返回的数据类型不受支持",
468
+ content="The tool has returned a data type that is not supported.",
467
469
  ),
468
470
  )
469
471
 
@@ -480,7 +482,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
480
482
  ToolCallMessageSegment(
481
483
  role="tool",
482
484
  tool_call_id=func_tool_id,
483
- content="*工具没有返回值或者将结果直接发送给了用户*",
485
+ content="The tool has no return value, or has sent the result directly to the user.",
484
486
  ),
485
487
  )
486
488
  else:
@@ -492,7 +494,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
492
494
  ToolCallMessageSegment(
493
495
  role="tool",
494
496
  tool_call_id=func_tool_id,
495
- content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
497
+ content="*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
496
498
  ),
497
499
  )
498
500
 
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
5
5
 
6
6
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
7
7
 
8
- VERSION = "4.11.3"
8
+ VERSION = "4.12.0"
9
9
  DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
10
10
 
11
11
  WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -113,6 +113,14 @@ DEFAULT_CONFIG = {
113
113
  "provider": "moonshotai",
114
114
  "moonshotai_api_key": "",
115
115
  },
116
+ "sandbox": {
117
+ "enable": False,
118
+ "booter": "shipyard",
119
+ "shipyard_endpoint": "",
120
+ "shipyard_access_token": "",
121
+ "shipyard_ttl": 3600,
122
+ "shipyard_max_sessions": 10,
123
+ },
116
124
  },
117
125
  "provider_stt_settings": {
118
126
  "enable": False,
@@ -242,7 +250,7 @@ CONFIG_METADATA_2 = {
242
250
  "callback_server_host": "0.0.0.0",
243
251
  "port": 6196,
244
252
  },
245
- "OneBot v11 (QQ 个人号等)": {
253
+ "OneBot v11": {
246
254
  "id": "default",
247
255
  "type": "aiocqhttp",
248
256
  "enable": False,
@@ -989,17 +997,6 @@ CONFIG_METADATA_2 = {
989
997
  "api_base": "http://127.0.0.1:1234/v1",
990
998
  "custom_headers": {},
991
999
  },
992
- "ModelStack": {
993
- "id": "modelstack",
994
- "provider": "modelstack",
995
- "type": "openai_chat_completion",
996
- "provider_type": "chat_completion",
997
- "enable": True,
998
- "key": [],
999
- "api_base": "https://modelstack.app/v1",
1000
- "timeout": 120,
1001
- "custom_headers": {},
1002
- },
1003
1000
  "Gemini_OpenAI_API": {
1004
1001
  "id": "google_gemini_openai",
1005
1002
  "provider": "google",
@@ -2550,6 +2547,62 @@ CONFIG_METADATA_3 = {
2550
2547
  # "provider_settings.enable": True,
2551
2548
  # },
2552
2549
  # },
2550
+ "sandbox": {
2551
+ "description": "Agent 沙箱环境",
2552
+ "type": "object",
2553
+ "items": {
2554
+ "provider_settings.sandbox.enable": {
2555
+ "description": "启用沙箱环境",
2556
+ "type": "bool",
2557
+ "hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。",
2558
+ },
2559
+ "provider_settings.sandbox.booter": {
2560
+ "description": "沙箱环境驱动器",
2561
+ "type": "string",
2562
+ "options": ["shipyard"],
2563
+ "condition": {
2564
+ "provider_settings.sandbox.enable": True,
2565
+ },
2566
+ },
2567
+ "provider_settings.sandbox.shipyard_endpoint": {
2568
+ "description": "Shipyard API Endpoint",
2569
+ "type": "string",
2570
+ "hint": "Shipyard 服务的 API 访问地址。",
2571
+ "condition": {
2572
+ "provider_settings.sandbox.enable": True,
2573
+ "provider_settings.sandbox.booter": "shipyard",
2574
+ },
2575
+ "_special": "check_shipyard_connection",
2576
+ },
2577
+ "provider_settings.sandbox.shipyard_access_token": {
2578
+ "description": "Shipyard Access Token",
2579
+ "type": "string",
2580
+ "hint": "用于访问 Shipyard 服务的访问令牌。",
2581
+ "condition": {
2582
+ "provider_settings.sandbox.enable": True,
2583
+ "provider_settings.sandbox.booter": "shipyard",
2584
+ },
2585
+ },
2586
+ "provider_settings.sandbox.shipyard_ttl": {
2587
+ "description": "Shipyard Session TTL",
2588
+ "type": "int",
2589
+ "hint": "Shipyard 会话的生存时间(秒)。",
2590
+ "condition": {
2591
+ "provider_settings.sandbox.enable": True,
2592
+ "provider_settings.sandbox.booter": "shipyard",
2593
+ },
2594
+ },
2595
+ "provider_settings.sandbox.shipyard_max_sessions": {
2596
+ "description": "Shipyard Max Sessions",
2597
+ "type": "int",
2598
+ "hint": "Shipyard 最大会话数量。",
2599
+ "condition": {
2600
+ "provider_settings.sandbox.enable": True,
2601
+ "provider_settings.sandbox.booter": "shipyard",
2602
+ },
2603
+ },
2604
+ },
2605
+ },
2553
2606
  "truncate_and_compress": {
2554
2607
  "description": "上下文管理策略",
2555
2608
  "type": "object",
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
9
9
 
10
10
  from astrbot.core.db.po import (
11
11
  Attachment,
12
+ ChatUIProject,
12
13
  CommandConfig,
13
14
  CommandConflict,
14
15
  ConversationV2,
@@ -17,6 +18,7 @@ from astrbot.core.db.po import (
17
18
  PlatformSession,
18
19
  PlatformStat,
19
20
  Preference,
21
+ SessionProjectRelation,
20
22
  Stats,
21
23
  )
22
24
 
@@ -446,8 +448,11 @@ class BaseDatabase(abc.ABC):
446
448
  platform_id: str | None = None,
447
449
  page: int = 1,
448
450
  page_size: int = 20,
449
- ) -> list[PlatformSession]:
450
- """Get all Platform sessions for a specific creator (username) and optionally platform."""
451
+ ) -> list[dict]:
452
+ """Get all Platform sessions for a specific creator (username) and optionally platform.
453
+
454
+ Returns a list of dicts containing session info and project info (if session belongs to a project).
455
+ """
451
456
  ...
452
457
 
453
458
  @abc.abstractmethod
@@ -463,3 +468,80 @@ class BaseDatabase(abc.ABC):
463
468
  async def delete_platform_session(self, session_id: str) -> None:
464
469
  """Delete a Platform session by its ID."""
465
470
  ...
471
+
472
+ # ====
473
+ # ChatUI Project Management
474
+ # ====
475
+
476
+ @abc.abstractmethod
477
+ async def create_chatui_project(
478
+ self,
479
+ creator: str,
480
+ title: str,
481
+ emoji: str | None = "📁",
482
+ description: str | None = None,
483
+ ) -> ChatUIProject:
484
+ """Create a new ChatUI project."""
485
+ ...
486
+
487
+ @abc.abstractmethod
488
+ async def get_chatui_project_by_id(self, project_id: str) -> ChatUIProject | None:
489
+ """Get a ChatUI project by its ID."""
490
+ ...
491
+
492
+ @abc.abstractmethod
493
+ async def get_chatui_projects_by_creator(
494
+ self,
495
+ creator: str,
496
+ page: int = 1,
497
+ page_size: int = 100,
498
+ ) -> list[ChatUIProject]:
499
+ """Get all ChatUI projects for a specific creator."""
500
+ ...
501
+
502
+ @abc.abstractmethod
503
+ async def update_chatui_project(
504
+ self,
505
+ project_id: str,
506
+ title: str | None = None,
507
+ emoji: str | None = None,
508
+ description: str | None = None,
509
+ ) -> None:
510
+ """Update a ChatUI project."""
511
+ ...
512
+
513
+ @abc.abstractmethod
514
+ async def delete_chatui_project(self, project_id: str) -> None:
515
+ """Delete a ChatUI project by its ID."""
516
+ ...
517
+
518
+ @abc.abstractmethod
519
+ async def add_session_to_project(
520
+ self,
521
+ session_id: str,
522
+ project_id: str,
523
+ ) -> SessionProjectRelation:
524
+ """Add a session to a project."""
525
+ ...
526
+
527
+ @abc.abstractmethod
528
+ async def remove_session_from_project(self, session_id: str) -> None:
529
+ """Remove a session from its project."""
530
+ ...
531
+
532
+ @abc.abstractmethod
533
+ async def get_project_sessions(
534
+ self,
535
+ project_id: str,
536
+ page: int = 1,
537
+ page_size: int = 100,
538
+ ) -> list[PlatformSession]:
539
+ """Get all sessions in a project."""
540
+ ...
541
+
542
+ @abc.abstractmethod
543
+ async def get_project_by_session(
544
+ self, session_id: str, creator: str
545
+ ) -> ChatUIProject | None:
546
+ """Get the project that a session belongs to."""
547
+ ...
astrbot/core/db/po.py CHANGED
@@ -239,6 +239,71 @@ class Attachment(SQLModel, table=True):
239
239
  )
240
240
 
241
241
 
242
+ class ChatUIProject(SQLModel, table=True):
243
+ """This class represents projects for organizing ChatUI conversations.
244
+
245
+ Projects allow users to group related conversations together.
246
+ """
247
+
248
+ __tablename__: str = "chatui_projects"
249
+
250
+ inner_id: int | None = Field(
251
+ primary_key=True,
252
+ sa_column_kwargs={"autoincrement": True},
253
+ default=None,
254
+ )
255
+ project_id: str = Field(
256
+ max_length=36,
257
+ nullable=False,
258
+ unique=True,
259
+ default_factory=lambda: str(uuid.uuid4()),
260
+ )
261
+ creator: str = Field(nullable=False)
262
+ """Username of the project creator"""
263
+ emoji: str | None = Field(default="📁", max_length=10)
264
+ """Emoji icon for the project"""
265
+ title: str = Field(nullable=False, max_length=255)
266
+ """Title of the project"""
267
+ description: str | None = Field(default=None, max_length=1000)
268
+ """Description of the project"""
269
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
270
+ updated_at: datetime = Field(
271
+ default_factory=lambda: datetime.now(timezone.utc),
272
+ sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
273
+ )
274
+
275
+ __table_args__ = (
276
+ UniqueConstraint(
277
+ "project_id",
278
+ name="uix_chatui_project_id",
279
+ ),
280
+ )
281
+
282
+
283
+ class SessionProjectRelation(SQLModel, table=True):
284
+ """This class represents the relationship between platform sessions and ChatUI projects."""
285
+
286
+ __tablename__: str = "session_project_relations"
287
+
288
+ id: int | None = Field(
289
+ primary_key=True,
290
+ sa_column_kwargs={"autoincrement": True},
291
+ default=None,
292
+ )
293
+ session_id: str = Field(nullable=False, max_length=100)
294
+ """Session ID from PlatformSession"""
295
+ project_id: str = Field(nullable=False, max_length=36)
296
+ """Project ID from ChatUIProject"""
297
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
298
+
299
+ __table_args__ = (
300
+ UniqueConstraint(
301
+ "session_id",
302
+ name="uix_session_project_relation",
303
+ ),
304
+ )
305
+
306
+
242
307
  class CommandConfig(SQLModel, table=True):
243
308
  """Per-command configuration overrides for dashboard management."""
244
309
 
astrbot/core/db/sqlite.py CHANGED
@@ -11,6 +11,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
11
11
  from astrbot.core.db import BaseDatabase
12
12
  from astrbot.core.db.po import (
13
13
  Attachment,
14
+ ChatUIProject,
14
15
  CommandConfig,
15
16
  CommandConflict,
16
17
  ConversationV2,
@@ -19,6 +20,7 @@ from astrbot.core.db.po import (
19
20
  PlatformSession,
20
21
  PlatformStat,
21
22
  Preference,
23
+ SessionProjectRelation,
22
24
  SQLModel,
23
25
  )
24
26
  from astrbot.core.db.po import (
@@ -1060,12 +1062,35 @@ class SQLiteDatabase(BaseDatabase):
1060
1062
  platform_id: str | None = None,
1061
1063
  page: int = 1,
1062
1064
  page_size: int = 20,
1063
- ) -> list[PlatformSession]:
1064
- """Get all Platform sessions for a specific creator (username) and optionally platform."""
1065
+ ) -> list[dict]:
1066
+ """Get all Platform sessions for a specific creator (username) and optionally platform.
1067
+
1068
+ Returns a list of dicts containing session info and project info (if session belongs to a project).
1069
+ """
1065
1070
  async with self.get_db() as session:
1066
1071
  session: AsyncSession
1067
1072
  offset = (page - 1) * page_size
1068
- query = select(PlatformSession).where(PlatformSession.creator == creator)
1073
+
1074
+ # LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
1075
+ query = (
1076
+ select(
1077
+ PlatformSession,
1078
+ col(ChatUIProject.project_id),
1079
+ col(ChatUIProject.title).label("project_title"),
1080
+ col(ChatUIProject.emoji).label("project_emoji"),
1081
+ )
1082
+ .outerjoin(
1083
+ SessionProjectRelation,
1084
+ col(PlatformSession.session_id)
1085
+ == col(SessionProjectRelation.session_id),
1086
+ )
1087
+ .outerjoin(
1088
+ ChatUIProject,
1089
+ col(SessionProjectRelation.project_id)
1090
+ == col(ChatUIProject.project_id),
1091
+ )
1092
+ .where(col(PlatformSession.creator) == creator)
1093
+ )
1069
1094
 
1070
1095
  if platform_id:
1071
1096
  query = query.where(PlatformSession.platform_id == platform_id)
@@ -1076,7 +1101,24 @@ class SQLiteDatabase(BaseDatabase):
1076
1101
  .limit(page_size)
1077
1102
  )
1078
1103
  result = await session.execute(query)
1079
- return list(result.scalars().all())
1104
+
1105
+ # Convert to list of dicts with session and project info
1106
+ sessions_with_projects = []
1107
+ for row in result.all():
1108
+ platform_session = row[0]
1109
+ project_id = row[1]
1110
+ project_title = row[2]
1111
+ project_emoji = row[3]
1112
+
1113
+ session_dict = {
1114
+ "session": platform_session,
1115
+ "project_id": project_id,
1116
+ "project_title": project_title,
1117
+ "project_emoji": project_emoji,
1118
+ }
1119
+ sessions_with_projects.append(session_dict)
1120
+
1121
+ return sessions_with_projects
1080
1122
 
1081
1123
  async def update_platform_session(
1082
1124
  self,
@@ -1107,3 +1149,182 @@ class SQLiteDatabase(BaseDatabase):
1107
1149
  col(PlatformSession.session_id) == session_id,
1108
1150
  ),
1109
1151
  )
1152
+
1153
+ # ====
1154
+ # ChatUI Project Management
1155
+ # ====
1156
+
1157
+ async def create_chatui_project(
1158
+ self,
1159
+ creator: str,
1160
+ title: str,
1161
+ emoji: str | None = "📁",
1162
+ description: str | None = None,
1163
+ ) -> ChatUIProject:
1164
+ """Create a new ChatUI project."""
1165
+ async with self.get_db() as session:
1166
+ session: AsyncSession
1167
+ async with session.begin():
1168
+ project = ChatUIProject(
1169
+ creator=creator,
1170
+ title=title,
1171
+ emoji=emoji,
1172
+ description=description,
1173
+ )
1174
+ session.add(project)
1175
+ await session.flush()
1176
+ await session.refresh(project)
1177
+ return project
1178
+
1179
+ async def get_chatui_project_by_id(self, project_id: str) -> ChatUIProject | None:
1180
+ """Get a ChatUI project by its ID."""
1181
+ async with self.get_db() as session:
1182
+ session: AsyncSession
1183
+ result = await session.execute(
1184
+ select(ChatUIProject).where(
1185
+ col(ChatUIProject.project_id) == project_id,
1186
+ ),
1187
+ )
1188
+ return result.scalar_one_or_none()
1189
+
1190
+ async def get_chatui_projects_by_creator(
1191
+ self,
1192
+ creator: str,
1193
+ page: int = 1,
1194
+ page_size: int = 100,
1195
+ ) -> list[ChatUIProject]:
1196
+ """Get all ChatUI projects for a specific creator."""
1197
+ async with self.get_db() as session:
1198
+ session: AsyncSession
1199
+ offset = (page - 1) * page_size
1200
+ result = await session.execute(
1201
+ select(ChatUIProject)
1202
+ .where(col(ChatUIProject.creator) == creator)
1203
+ .order_by(desc(ChatUIProject.updated_at))
1204
+ .limit(page_size)
1205
+ .offset(offset),
1206
+ )
1207
+ return list(result.scalars().all())
1208
+
1209
+ async def update_chatui_project(
1210
+ self,
1211
+ project_id: str,
1212
+ title: str | None = None,
1213
+ emoji: str | None = None,
1214
+ description: str | None = None,
1215
+ ) -> None:
1216
+ """Update a ChatUI project."""
1217
+ async with self.get_db() as session:
1218
+ session: AsyncSession
1219
+ async with session.begin():
1220
+ values: dict[str, T.Any] = {"updated_at": datetime.now(timezone.utc)}
1221
+ if title is not None:
1222
+ values["title"] = title
1223
+ if emoji is not None:
1224
+ values["emoji"] = emoji
1225
+ if description is not None:
1226
+ values["description"] = description
1227
+
1228
+ await session.execute(
1229
+ update(ChatUIProject)
1230
+ .where(col(ChatUIProject.project_id) == project_id)
1231
+ .values(**values),
1232
+ )
1233
+
1234
+ async def delete_chatui_project(self, project_id: str) -> None:
1235
+ """Delete a ChatUI project by its ID."""
1236
+ async with self.get_db() as session:
1237
+ session: AsyncSession
1238
+ async with session.begin():
1239
+ # First remove all session relations
1240
+ await session.execute(
1241
+ delete(SessionProjectRelation).where(
1242
+ col(SessionProjectRelation.project_id) == project_id,
1243
+ ),
1244
+ )
1245
+ # Then delete the project
1246
+ await session.execute(
1247
+ delete(ChatUIProject).where(
1248
+ col(ChatUIProject.project_id) == project_id,
1249
+ ),
1250
+ )
1251
+
1252
+ async def add_session_to_project(
1253
+ self,
1254
+ session_id: str,
1255
+ project_id: str,
1256
+ ) -> SessionProjectRelation:
1257
+ """Add a session to a project."""
1258
+ async with self.get_db() as session:
1259
+ session: AsyncSession
1260
+ async with session.begin():
1261
+ # First remove existing relation if any
1262
+ await session.execute(
1263
+ delete(SessionProjectRelation).where(
1264
+ col(SessionProjectRelation.session_id) == session_id,
1265
+ ),
1266
+ )
1267
+ # Then create new relation
1268
+ relation = SessionProjectRelation(
1269
+ session_id=session_id,
1270
+ project_id=project_id,
1271
+ )
1272
+ session.add(relation)
1273
+ await session.flush()
1274
+ await session.refresh(relation)
1275
+ return relation
1276
+
1277
+ async def remove_session_from_project(self, session_id: str) -> None:
1278
+ """Remove a session from its project."""
1279
+ async with self.get_db() as session:
1280
+ session: AsyncSession
1281
+ async with session.begin():
1282
+ await session.execute(
1283
+ delete(SessionProjectRelation).where(
1284
+ col(SessionProjectRelation.session_id) == session_id,
1285
+ ),
1286
+ )
1287
+
1288
+ async def get_project_sessions(
1289
+ self,
1290
+ project_id: str,
1291
+ page: int = 1,
1292
+ page_size: int = 100,
1293
+ ) -> list[PlatformSession]:
1294
+ """Get all sessions in a project."""
1295
+ async with self.get_db() as session:
1296
+ session: AsyncSession
1297
+ offset = (page - 1) * page_size
1298
+ result = await session.execute(
1299
+ select(PlatformSession)
1300
+ .join(
1301
+ SessionProjectRelation,
1302
+ col(PlatformSession.session_id)
1303
+ == col(SessionProjectRelation.session_id),
1304
+ )
1305
+ .where(col(SessionProjectRelation.project_id) == project_id)
1306
+ .order_by(desc(PlatformSession.updated_at))
1307
+ .limit(page_size)
1308
+ .offset(offset),
1309
+ )
1310
+ return list(result.scalars().all())
1311
+
1312
+ async def get_project_by_session(
1313
+ self, session_id: str, creator: str
1314
+ ) -> ChatUIProject | None:
1315
+ """Get the project that a session belongs to."""
1316
+ async with self.get_db() as session:
1317
+ session: AsyncSession
1318
+ result = await session.execute(
1319
+ select(ChatUIProject)
1320
+ .join(
1321
+ SessionProjectRelation,
1322
+ col(ChatUIProject.project_id)
1323
+ == col(SessionProjectRelation.project_id),
1324
+ )
1325
+ .where(
1326
+ col(SessionProjectRelation.session_id) == session_id,
1327
+ col(ChatUIProject.creator) == creator,
1328
+ ),
1329
+ )
1330
+ return result.scalar_one_or_none()