Add Artifact model and owner-scoped artifacts API

GET /api/artifacts?conversation_id= lists ArtifactRefs without data;
GET /api/artifacts/<id> returns the full artifact. Both 404 for other
users. create_artifact_from_output persists ToolArtifact dicts and drops
ones over 256 KB serialised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:52:30 -04:00
parent d2f21a6d23
commit 0cf6229441
5 changed files with 159 additions and 0 deletions
+2
View File
@@ -16,6 +16,7 @@ from quart import (
from quart_jwt_extended import JWTManager, get_jwt_identity
from tortoise import Tortoise
import blueprints.artifacts
import blueprints.conversation
import blueprints.conversation.logic
import blueprints.email
@@ -71,6 +72,7 @@ app.register_blueprint(blueprints.imessage.imessage_blueprint)
app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint)
app.register_blueprint(blueprints.cron.cron_blueprint)
app.register_blueprint(blueprints.beli.beli_blueprint)
app.register_blueprint(blueprints.artifacts.artifacts_blueprint)
async def _obsidian_sync_loop():
+49
View File
@@ -0,0 +1,49 @@
from quart import Blueprint, jsonify, request
from quart_jwt_extended import get_jwt_identity
from blueprints.conversation.models import Conversation
from blueprints.users.decorators import jwt_access_required
from .logic import artifact_full, artifact_summary
from .models import Artifact
artifacts_blueprint = Blueprint("artifacts_api", __name__, url_prefix="/api/artifacts")
def _not_found():
return jsonify({"error": "artifact not found"}), 404
@artifacts_blueprint.get("")
@jwt_access_required
async def list_artifacts():
user_uuid = get_jwt_identity()
conversation_id = request.args.get("conversation_id", "").strip()
if not conversation_id:
return jsonify({"error": "conversation_id is required"}), 400
try:
conversation = await Conversation.get_or_none(
id=conversation_id, user_id=user_uuid
)
except ValueError:
conversation = None
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
artifacts = await Artifact.filter(
conversation_id=conversation.id, user_id=user_uuid
).order_by("created_at")
return jsonify([artifact_summary(a) for a in artifacts])
@artifacts_blueprint.get("/<artifact_id>")
@jwt_access_required
async def get_artifact(artifact_id: str):
user_uuid = get_jwt_identity()
try:
artifact = await Artifact.get_or_none(id=artifact_id, user_id=user_uuid)
except ValueError:
artifact = None
if artifact is None:
return _not_found()
return jsonify(artifact_full(artifact))
+78
View File
@@ -0,0 +1,78 @@
import json
import logging
from .models import Artifact
logger = logging.getLogger(__name__)
# Artifacts whose serialised `data` exceeds this are dropped (the tool text still persists).
ARTIFACT_MAX_BYTES = 256 * 1024
def artifact_ref(artifact: Artifact) -> dict:
"""The lightweight reference sent in SSE events and the conversation payload."""
return {"id": str(artifact.id), "kind": artifact.kind, "title": artifact.title}
def artifact_summary(artifact: Artifact) -> dict:
"""ArtifactRef plus listing metadata, without `data`."""
return {
**artifact_ref(artifact),
"tool": artifact.tool,
"message_id": str(artifact.message_id) if artifact.message_id else None,
"created_at": artifact.created_at.isoformat(),
}
def artifact_full(artifact: Artifact) -> dict:
return {
**artifact_summary(artifact),
"data": artifact.data,
"source_ref": artifact.source_ref,
"conversation_id": str(artifact.conversation_id),
}
async def create_artifact_from_output(
*, output, tool: str, user, conversation, message=None
) -> Artifact | None:
"""Persist `output.artifact` if it is a ToolArtifact dict; otherwise return None.
Tools opt in via `@tool(response_format="content_and_artifact")`. Artifacts
without a `kind` (e.g. simba_search's raw Documents) are ignored, as are
ones whose `data` is not JSON-serialisable or over ARTIFACT_MAX_BYTES.
"""
raw = getattr(output, "artifact", None)
if not isinstance(raw, dict) or not raw.get("kind"):
return None
data = raw.get("data") or {}
source_ref = raw.get("source_ref")
try:
size = len(json.dumps(data).encode("utf-8"))
json.dumps(source_ref)
except (TypeError, ValueError) as e:
logger.warning(
"Dropping %s artifact from %s: not serialisable (%s)", raw["kind"], tool, e
)
return None
if size > ARTIFACT_MAX_BYTES:
logger.warning(
"Dropping %s artifact from %s: %d bytes exceeds %d",
raw["kind"],
tool,
size,
ARTIFACT_MAX_BYTES,
)
return None
return await Artifact.create(
user=user,
conversation=conversation,
message=message,
tool=tool,
kind=str(raw["kind"])[:64],
title=str(raw.get("title") or "")[:255],
data=data,
source_ref=source_ref,
)
+29
View File
@@ -0,0 +1,29 @@
from tortoise import fields
from tortoise.models import Model
class Artifact(Model):
"""A structured tool result (recipe, budget, note…) rendered in the artifact panel."""
id = fields.UUIDField(primary_key=True)
user = fields.ForeignKeyField("models.User", related_name="artifacts")
conversation = fields.ForeignKeyField(
"models.Conversation", related_name="artifacts", on_delete=fields.CASCADE
)
message = fields.ForeignKeyField(
"models.ConversationMessage",
related_name="artifacts",
null=True,
on_delete=fields.SET_NULL,
)
tool = fields.CharField(max_length=255)
kind = fields.CharField(max_length=64)
title = fields.CharField(max_length=255)
data = fields.JSONField()
source_ref = fields.JSONField(null=True)
version = fields.IntField(default=1)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "artifacts"
+1
View File
@@ -19,6 +19,7 @@ TORTOISE_CONFIG = {
"blueprints.scheduled_messages.models",
"blueprints.cron.models",
"blueprints.beli.models",
"blueprints.artifacts.models",
"aerich.models",
],
"default_connection": "default",