Skip to content

Commit b821935

Browse files
authored
feat: add database to store conversation (#1041)
* feat(convDatabase): add sqlalchemy and alembic * feat(convDatabase): implement a first version of the conversation database * chore: ignore claude code settings * refactor(convDatabase): optimize subsequent save query * feat(convDatabase): add settings and private mode * refactor(convDatabase): manage database singleton inside wx app singleton * refactor(convDatabase): optimize database manager code * fix(convDatabase): code review comment * fix(convDatabase): use correct method name * fix(convDatabase): disable btn when no conversation history is selected
1 parent 48aa53c commit b821935

23 files changed

+3334
-15
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ basilisk_config.yml
3232
*.mo
3333
user_data/
3434
coverage.xml
35+
.claude/settings.local.json

basilisk/config/main_config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ class ConversationSettings(BaseModel):
4545
shift_enter_mode: bool = Field(default=False)
4646
use_accessible_output: bool = Field(default=True)
4747
focus_history_after_send: bool = Field(default=False)
48+
auto_save_to_db: bool = Field(default=True)
49+
auto_save_draft: bool = Field(default=True)
50+
reopen_last_conversation: bool = Field(default=False)
51+
last_active_conversation_id: int | None = Field(default=None)
4852

4953

5054
class ImagesSettings(BaseModel):

basilisk/conversation/attached_file.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ class AttachmentFile(BaseModel):
226226
description: str | None = None
227227
size: int | None = None
228228
mime_type: str | None = None
229+
db_id: int | None = Field(default=None, exclude=True)
229230

230231
@field_serializer("location", mode="wrap")
231232
@classmethod

basilisk/conversation/conversation_model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ class SystemMessage(BaseMessage):
9090
"""Represents a system message in a conversation. The system message is used to provide instructions or context to the assistant."""
9191

9292
role: MessageRoleEnum = Field(default=MessageRoleEnum.SYSTEM)
93+
db_id: int | None = Field(default=None, exclude=True)
9394

9495
@field_validator("role", mode="after")
9596
@classmethod
@@ -131,6 +132,7 @@ class MessageBlock(BaseModel):
131132
stream: bool = Field(default=False)
132133
created_at: datetime = Field(default_factory=datetime.now)
133134
updated_at: datetime = Field(default_factory=datetime.now)
135+
db_id: int | None = Field(default=None, exclude=True)
134136

135137
@field_validator("response", mode="after")
136138
@classmethod
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Database package for conversation persistence."""
2+
3+
import logging
4+
from pathlib import Path
5+
6+
from basilisk import global_vars
7+
from basilisk.consts import APP_AUTHOR, APP_NAME
8+
9+
from .manager import ConversationDatabase
10+
11+
log = logging.getLogger(__name__)
12+
13+
14+
def get_db_path() -> Path:
15+
"""Determine the path for the conversation database file."""
16+
if global_vars.user_data_path:
17+
db_dir = global_vars.user_data_path
18+
else:
19+
from platformdirs import user_data_path
20+
21+
db_dir = user_data_path(APP_NAME, APP_AUTHOR, ensure_exists=True)
22+
return db_dir / "conversations.db"
23+
24+
25+
__all__ = ["ConversationDatabase", "get_db_path"]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Alembic environment configuration for conversation database."""
2+
3+
from alembic import context
4+
from sqlalchemy import engine_from_config, pool
5+
6+
from basilisk.conversation.database.models import Base
7+
8+
target_metadata = Base.metadata
9+
10+
11+
def run_migrations_offline():
12+
"""Run migrations in 'offline' mode."""
13+
url = context.config.get_main_option("sqlalchemy.url")
14+
context.configure(
15+
url=url,
16+
target_metadata=target_metadata,
17+
literal_binds=True,
18+
dialect_opts={"paramstyle": "named"},
19+
)
20+
21+
with context.begin_transaction():
22+
context.run_migrations()
23+
24+
25+
def run_migrations_online():
26+
"""Run migrations in 'online' mode."""
27+
connectable = engine_from_config(
28+
context.config.get_section(context.config.config_ini_section, {}),
29+
prefix="sqlalchemy.",
30+
poolclass=pool.NullPool,
31+
)
32+
33+
with connectable.connect() as connection:
34+
context.configure(
35+
connection=connection, target_metadata=target_metadata
36+
)
37+
38+
with context.begin_transaction():
39+
context.run_migrations()
40+
41+
42+
if context.is_offline_mode():
43+
run_migrations_offline()
44+
else:
45+
run_migrations_online()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
${imports if imports else ""}
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = ${repr(up_revision)}
16+
down_revision: Union[str, None] = ${repr(down_revision)}
17+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19+
20+
21+
def upgrade() -> None:
22+
${upgrades if upgrades else "pass"}
23+
24+
25+
def downgrade() -> None:
26+
${downgrades if downgrades else "pass"}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Initial schema for conversation database.
2+
3+
Revision ID: 001
4+
Revises:
5+
Create Date: 2026-02-15
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
revision: str = "001"
15+
down_revision: Union[str, None] = None
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def upgrade() -> None:
21+
"""Create all tables for conversation persistence."""
22+
# conversations
23+
op.create_table(
24+
"conversations",
25+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
26+
sa.Column("title", sa.String(), nullable=True),
27+
sa.Column("created_at", sa.DateTime(), nullable=False),
28+
sa.Column("updated_at", sa.DateTime(), nullable=False),
29+
)
30+
op.create_index(
31+
"ix_conversations_updated",
32+
"conversations",
33+
[sa.column("updated_at").desc()],
34+
)
35+
36+
# system_prompts
37+
op.create_table(
38+
"system_prompts",
39+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
40+
sa.Column("content_hash", sa.String(), nullable=False, unique=True),
41+
sa.Column("content", sa.String(), nullable=False),
42+
)
43+
44+
# conversation_system_prompts
45+
op.create_table(
46+
"conversation_system_prompts",
47+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
48+
sa.Column("conversation_id", sa.Integer(), nullable=False),
49+
sa.Column("system_prompt_id", sa.Integer(), nullable=False),
50+
sa.Column("position", sa.Integer(), nullable=False),
51+
sa.ForeignKeyConstraint(
52+
["conversation_id"], ["conversations.id"], ondelete="CASCADE"
53+
),
54+
sa.ForeignKeyConstraint(["system_prompt_id"], ["system_prompts.id"]),
55+
sa.UniqueConstraint("conversation_id", "position"),
56+
)
57+
op.create_index(
58+
"ix_conv_sys_prompts_conv",
59+
"conversation_system_prompts",
60+
["conversation_id"],
61+
)
62+
63+
# message_blocks
64+
op.create_table(
65+
"message_blocks",
66+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
67+
sa.Column("conversation_id", sa.Integer(), nullable=False),
68+
sa.Column("position", sa.Integer(), nullable=False),
69+
sa.Column("conversation_system_prompt_id", sa.Integer(), nullable=True),
70+
sa.Column("model_provider", sa.String(), nullable=False),
71+
sa.Column("model_id", sa.String(), nullable=False),
72+
sa.Column(
73+
"temperature", sa.Float(), nullable=False, server_default="1.0"
74+
),
75+
sa.Column(
76+
"max_tokens", sa.Integer(), nullable=False, server_default="4096"
77+
),
78+
sa.Column("top_p", sa.Float(), nullable=False, server_default="1.0"),
79+
sa.Column("stream", sa.Boolean(), nullable=False, server_default="0"),
80+
sa.Column("created_at", sa.DateTime(), nullable=False),
81+
sa.Column("updated_at", sa.DateTime(), nullable=False),
82+
sa.ForeignKeyConstraint(
83+
["conversation_id"], ["conversations.id"], ondelete="CASCADE"
84+
),
85+
sa.ForeignKeyConstraint(
86+
["conversation_system_prompt_id"],
87+
["conversation_system_prompts.id"],
88+
),
89+
sa.UniqueConstraint("conversation_id", "position"),
90+
)
91+
op.create_index(
92+
"ix_message_blocks_conversation",
93+
"message_blocks",
94+
["conversation_id", "position"],
95+
)
96+
97+
# messages
98+
op.create_table(
99+
"messages",
100+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
101+
sa.Column("message_block_id", sa.Integer(), nullable=False),
102+
sa.Column("role", sa.String(), nullable=False),
103+
sa.Column("content", sa.String(), nullable=False),
104+
sa.ForeignKeyConstraint(
105+
["message_block_id"], ["message_blocks.id"], ondelete="CASCADE"
106+
),
107+
sa.UniqueConstraint("message_block_id", "role"),
108+
)
109+
op.create_index("ix_messages_block", "messages", ["message_block_id"])
110+
111+
# attachments
112+
op.create_table(
113+
"attachments",
114+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
115+
sa.Column("content_hash", sa.String(), nullable=False, unique=True),
116+
sa.Column("name", sa.String(), nullable=True),
117+
sa.Column("mime_type", sa.String(), nullable=True),
118+
sa.Column("size", sa.Integer(), nullable=True),
119+
sa.Column("location_type", sa.String(), nullable=False),
120+
sa.Column("url", sa.String(), nullable=True),
121+
sa.Column("blob_data", sa.LargeBinary(), nullable=True),
122+
sa.Column("is_image", sa.Boolean(), nullable=False, server_default="0"),
123+
sa.Column("image_width", sa.Integer(), nullable=True),
124+
sa.Column("image_height", sa.Integer(), nullable=True),
125+
)
126+
127+
# message_attachments
128+
op.create_table(
129+
"message_attachments",
130+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
131+
sa.Column("message_id", sa.Integer(), nullable=False),
132+
sa.Column("attachment_id", sa.Integer(), nullable=False),
133+
sa.Column("position", sa.Integer(), nullable=False),
134+
sa.Column("description", sa.String(), nullable=True),
135+
sa.ForeignKeyConstraint(
136+
["message_id"], ["messages.id"], ondelete="CASCADE"
137+
),
138+
sa.ForeignKeyConstraint(["attachment_id"], ["attachments.id"]),
139+
sa.UniqueConstraint("message_id", "position"),
140+
)
141+
142+
# citations
143+
op.create_table(
144+
"citations",
145+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
146+
sa.Column("message_id", sa.Integer(), nullable=False),
147+
sa.Column("position", sa.Integer(), nullable=False),
148+
sa.Column("cited_text", sa.String(), nullable=True),
149+
sa.Column("source_title", sa.String(), nullable=True),
150+
sa.Column("source_url", sa.String(), nullable=True),
151+
sa.Column("start_index", sa.Integer(), nullable=True),
152+
sa.Column("end_index", sa.Integer(), nullable=True),
153+
sa.ForeignKeyConstraint(
154+
["message_id"], ["messages.id"], ondelete="CASCADE"
155+
),
156+
)
157+
158+
159+
def downgrade() -> None:
160+
"""Drop all conversation tables."""
161+
op.drop_table("citations")
162+
op.drop_table("message_attachments")
163+
op.drop_table("attachments")
164+
op.drop_table("messages")
165+
op.drop_table("message_blocks")
166+
op.drop_table("conversation_system_prompts")
167+
op.drop_table("system_prompts")
168+
op.drop_table("conversations")

0 commit comments

Comments
 (0)