mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +09:00
AI is a core ServerKit primitive (not a plugin): an Intercom-style bubble + slide-in drawer on every dashboard page, with a tools+page-context assistant mode and a plain simple chat mode, SSE streaming, conversation persistence, and guarded write actions (the model proposes; the user approves). Plugins EXTEND the assistant rather than rebuilding it: backend via app.plugins_sdk.ai (reg.tool + register_context_provider, discovered from a plugin.json ai block with install/enable/disable hooks); frontend via useServerkitAI() and a contributions.ai category (suggested_prompts, tool_renderers). The marketplace gains an ai category for discovery. Backend: ai_service (Prompture Conversation + ProviderEnvironment + guardrails), ai_tool_registry singleton, ai_tools_builtin (core.* tools), the /api/v1/ai blueprint (SSE via a worker-thread + queue bridge, single-gevent-worker safe; in-thread confirmation gate), AiConversation/AiMessage/AiPendingAction models + migration 013, encrypted ai_* settings. Frontend: AIContext + components/ai/*, AI settings tab. Powered by Prompture (installed editable; requirements pinned). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
7.3 KiB
Python
191 lines
7.3 KiB
Python
"""AI assistant persistence models (core primitive — powered by Prompture).
|
|
|
|
Three tables back the in-panel assistant:
|
|
|
|
- ``AiConversation`` — one chat thread per user. Holds the full Prompture
|
|
``Conversation.export()`` blob so a thread can be resumed across requests
|
|
(tools are re-supplied on resume; only the message/usage state is stored).
|
|
- ``AiMessage`` — denormalized per-turn rows for fast list/transcript rendering
|
|
without re-parsing the export blob (also replays tool-call cards).
|
|
- ``AiPendingAction`` — audit/record of a guarded write-tool confirmation. The
|
|
live approve/deny coordination happens in-memory (see ai_service streaming),
|
|
but every request is persisted here for visibility and audit.
|
|
|
|
JSON columns use the store-as-Text + property pattern from ``plugin.py`` to stay
|
|
dialect-agnostic (SQLite/PostgreSQL).
|
|
"""
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
|
|
from app import db
|
|
|
|
|
|
def _new_id() -> str:
|
|
"""Opaque, URL-safe conversation id (also used as Prompture conversation_id)."""
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
class AiConversation(db.Model):
|
|
"""A single AI chat thread owned by a user."""
|
|
__tablename__ = 'ai_conversations'
|
|
|
|
MODE_ASSISTANT = 'assistant' # tools + page context
|
|
MODE_SIMPLE = 'simple' # plain chat, no tools
|
|
|
|
id = db.Column(db.String(64), primary_key=True, default=_new_id)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True, nullable=False)
|
|
title = db.Column(db.String(256))
|
|
mode = db.Column(db.String(16), default=MODE_ASSISTANT)
|
|
model_name = db.Column(db.String(128)) # 'provider/model' snapshot at creation
|
|
export_json = db.Column(db.Text) # Prompture conv.export(strip_images=True)
|
|
last_page = db.Column(db.String(256)) # last route the assistant saw (for resume context)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
messages = db.relationship(
|
|
'AiMessage', backref='conversation', cascade='all, delete-orphan',
|
|
order_by='AiMessage.created_at', lazy='dynamic',
|
|
)
|
|
pending_actions = db.relationship(
|
|
'AiPendingAction', backref='conversation', cascade='all, delete-orphan', lazy='dynamic',
|
|
)
|
|
|
|
@property
|
|
def export(self) -> dict:
|
|
return json.loads(self.export_json) if self.export_json else {}
|
|
|
|
@export.setter
|
|
def export(self, value) -> None:
|
|
self.export_json = json.dumps(value) if value is not None else None
|
|
|
|
def to_dict(self, include_messages: bool = False) -> dict:
|
|
data = {
|
|
'id': self.id,
|
|
'user_id': self.user_id,
|
|
'title': self.title or 'New chat',
|
|
'mode': self.mode,
|
|
'model_name': self.model_name,
|
|
'last_page': self.last_page,
|
|
'message_count': self.messages.count(),
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
if include_messages:
|
|
data['messages'] = [m.to_dict() for m in self.messages]
|
|
return data
|
|
|
|
|
|
class AiMessage(db.Model):
|
|
"""One persisted turn in a conversation (user, assistant, or tool)."""
|
|
__tablename__ = 'ai_messages'
|
|
|
|
ROLE_USER = 'user'
|
|
ROLE_ASSISTANT = 'assistant'
|
|
ROLE_TOOL = 'tool'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
conversation_id = db.Column(
|
|
db.String(64), db.ForeignKey('ai_conversations.id', ondelete='CASCADE'), index=True, nullable=False,
|
|
)
|
|
role = db.Column(db.String(16), nullable=False)
|
|
content = db.Column(db.Text)
|
|
tool_calls_json = db.Column(db.Text) # [{id,name,input,output,is_error}] for ToolCallCard replay
|
|
usage_json = db.Column(db.Text) # {input_tokens, output_tokens, cost}
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
|
|
@property
|
|
def tool_calls(self) -> list:
|
|
return json.loads(self.tool_calls_json) if self.tool_calls_json else []
|
|
|
|
@tool_calls.setter
|
|
def tool_calls(self, value) -> None:
|
|
self.tool_calls_json = json.dumps(value) if value else None
|
|
|
|
@property
|
|
def usage(self) -> dict:
|
|
return json.loads(self.usage_json) if self.usage_json else {}
|
|
|
|
@usage.setter
|
|
def usage(self, value) -> None:
|
|
self.usage_json = json.dumps(value) if value else None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
'id': self.id,
|
|
'conversation_id': self.conversation_id,
|
|
'role': self.role,
|
|
'content': self.content,
|
|
'tool_calls': self.tool_calls,
|
|
'usage': self.usage,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
}
|
|
|
|
|
|
class AiPendingAction(db.Model):
|
|
"""A guarded write-tool action awaiting human confirmation.
|
|
|
|
Live approve/deny is coordinated in-memory by the streaming worker; this
|
|
row is the durable record (status + result) for audit and admin visibility.
|
|
"""
|
|
__tablename__ = 'ai_pending_actions'
|
|
|
|
STATUS_PENDING = 'pending'
|
|
STATUS_APPROVED = 'approved'
|
|
STATUS_DENIED = 'denied'
|
|
STATUS_EXPIRED = 'expired'
|
|
STATUS_EXECUTED = 'executed'
|
|
STATUS_FAILED = 'failed'
|
|
|
|
DEFAULT_TTL_SECONDS = 300
|
|
|
|
id = db.Column(db.String(64), primary_key=True) # action token (secrets.token_urlsafe)
|
|
conversation_id = db.Column(
|
|
db.String(64), db.ForeignKey('ai_conversations.id', ondelete='CASCADE'), index=True, nullable=False,
|
|
)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True, nullable=False)
|
|
tool_name = db.Column(db.String(128), nullable=False) # qualified name, e.g. core__restart_docker_container
|
|
plugin_slug = db.Column(db.String(128)) # None => built-in tool
|
|
params_json = db.Column(db.Text)
|
|
summary = db.Column(db.Text)
|
|
status = db.Column(db.String(16), default=STATUS_PENDING)
|
|
result_json = db.Column(db.Text)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
expires_at = db.Column(db.DateTime)
|
|
|
|
@property
|
|
def params(self) -> dict:
|
|
return json.loads(self.params_json) if self.params_json else {}
|
|
|
|
@params.setter
|
|
def params(self, value) -> None:
|
|
self.params_json = json.dumps(value) if value else None
|
|
|
|
@property
|
|
def result(self):
|
|
return json.loads(self.result_json) if self.result_json else None
|
|
|
|
@result.setter
|
|
def result(self, value) -> None:
|
|
self.result_json = json.dumps(value) if value is not None else None
|
|
|
|
def is_expired(self) -> bool:
|
|
return self.expires_at is not None and datetime.utcnow() > self.expires_at
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
'action_token': self.id,
|
|
'conversation_id': self.conversation_id,
|
|
'tool_name': self.tool_name,
|
|
'plugin_slug': self.plugin_slug,
|
|
'params': self.params,
|
|
'summary': self.summary,
|
|
'status': self.status,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
|
}
|
|
|
|
@classmethod
|
|
def make_expiry(cls, ttl_seconds: int | None = None) -> datetime:
|
|
return datetime.utcnow() + timedelta(seconds=ttl_seconds or cls.DEFAULT_TTL_SECONDS)
|