mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add report versioning support with report_group_id
This commit is contained in:
parent
e7a73d0570
commit
adeef35443
10 changed files with 344 additions and 48 deletions
|
|
@ -0,0 +1,70 @@
|
|||
"""Add report_group_id for report versioning
|
||||
|
||||
Revision ID: 100
|
||||
Revises: 99
|
||||
Create Date: 2026-02-11
|
||||
|
||||
Adds report_group_id column to reports table for grouping report versions.
|
||||
Reports with the same report_group_id are versions of the same report.
|
||||
For the first version (v1), report_group_id equals the report's own id.
|
||||
Migration is idempotent — safe to re-run.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "100"
|
||||
down_revision: str | None = "99"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add report_group_id column (idempotent)
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'reports' AND column_name = 'report_group_id'
|
||||
) THEN
|
||||
ALTER TABLE reports ADD COLUMN report_group_id INTEGER;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill existing reports: set report_group_id = id (each is its own v1)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE reports SET report_group_id = id WHERE report_group_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# Create index (idempotent)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_reports_report_group_id
|
||||
ON reports(report_group_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_reports_report_group_id")
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'reports' AND column_name = 'report_group_id'
|
||||
) THEN
|
||||
ALTER TABLE reports DROP COLUMN report_group_id;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ from app.services.llm_service import get_document_summary_llm
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Prompt template for report generation
|
||||
# Prompt template for report generation (new report from scratch)
|
||||
_REPORT_PROMPT = """You are an expert report writer. Generate a well-structured, comprehensive Markdown report based on the provided information.
|
||||
|
||||
**Topic:** {topic}
|
||||
|
|
@ -31,6 +31,8 @@ _REPORT_PROMPT = """You are an expert report writer. Generate a well-structured,
|
|||
|
||||
{user_instructions_section}
|
||||
|
||||
{previous_version_section}
|
||||
|
||||
**Source Content:**
|
||||
{source_content}
|
||||
|
||||
|
|
@ -94,6 +96,7 @@ def create_generate_report_tool(
|
|||
source_content: str,
|
||||
report_style: str = "detailed",
|
||||
user_instructions: str | None = None,
|
||||
parent_report_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate a structured Markdown report from provided content.
|
||||
|
|
@ -106,6 +109,29 @@ def create_generate_report_tool(
|
|||
- "Make a research report on..."
|
||||
- "Summarize this into a report"
|
||||
|
||||
VERSIONING — parent_report_id:
|
||||
- Set parent_report_id ONLY when the user explicitly asks to MODIFY,
|
||||
REVISE, IMPROVE, or UPDATE an existing report that was already
|
||||
generated in this conversation.
|
||||
- The value must be the report_id from a previous generate_report
|
||||
result in this same conversation.
|
||||
- Do NOT set parent_report_id when:
|
||||
* The user asks for a report on a NEW/DIFFERENT topic
|
||||
* The user says "generate another report" (new report, not a revision)
|
||||
* There is no prior report to reference
|
||||
- When parent_report_id is set, the previous report's content will be
|
||||
used as a base. Your user_instructions should describe WHAT TO CHANGE.
|
||||
|
||||
Examples of when to SET parent_report_id:
|
||||
User: "Make that report shorter" → parent_report_id = <previous report_id>
|
||||
User: "Add a cost analysis section to the report" → parent_report_id = <previous report_id>
|
||||
User: "Rewrite the report in a more formal tone" → parent_report_id = <previous report_id>
|
||||
|
||||
Examples of when to LEAVE parent_report_id as None:
|
||||
User: "Generate a report on climate change" → parent_report_id = None (new topic)
|
||||
User: "Write me a report about the budget" → parent_report_id = None (new topic)
|
||||
User: "Create another report, this time about marketing" → parent_report_id = None
|
||||
|
||||
Args:
|
||||
topic: A short, concise title for the report (maximum 8 words). Keep it brief and descriptive — e.g. "AI in Healthcare Analysis: A Comprehensive Report" instead of "Comprehensive Analysis of Artificial Intelligence Applications in Modern Healthcare Systems".
|
||||
source_content: The text content to base the report on. This MUST be comprehensive and include:
|
||||
|
|
@ -114,7 +140,8 @@ def create_generate_report_tool(
|
|||
* You can combine both: conversation context + search results for richer reports
|
||||
* The more detailed the source_content, the better the report quality
|
||||
report_style: Style of the report. Options: "detailed", "executive_summary", "deep_research", "brief". Default: "detailed"
|
||||
user_instructions: Optional specific instructions for the report (e.g., "focus on financial impacts", "include recommendations")
|
||||
user_instructions: Optional specific instructions for the report (e.g., "focus on financial impacts", "include recommendations"). When revising an existing report (parent_report_id is set), this should describe the changes to make.
|
||||
parent_report_id: Optional ID of a previously generated report to revise. When set, the new report is created as a new version in the same version group. The previous report's content is included as context for the LLM to refine.
|
||||
|
||||
Returns:
|
||||
A dictionary containing:
|
||||
|
|
@ -124,6 +151,24 @@ def create_generate_report_tool(
|
|||
- word_count: Number of words in the report
|
||||
- message: Status message (or "error" field if failed)
|
||||
"""
|
||||
# Resolve the parent report and its group (if versioning)
|
||||
parent_report: Report | None = None
|
||||
report_group_id: int | None = None
|
||||
|
||||
if parent_report_id:
|
||||
parent_report = await db_session.get(Report, parent_report_id)
|
||||
if parent_report:
|
||||
report_group_id = parent_report.report_group_id
|
||||
logger.info(
|
||||
f"[generate_report] Creating new version from parent {parent_report_id} "
|
||||
f"(group {report_group_id})"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[generate_report] parent_report_id={parent_report_id} not found, "
|
||||
"creating standalone report"
|
||||
)
|
||||
|
||||
async def _save_failed_report(error_msg: str) -> int | None:
|
||||
"""Persist a failed report row so the error is visible later."""
|
||||
try:
|
||||
|
|
@ -137,10 +182,15 @@ def create_generate_report_tool(
|
|||
report_style=report_style,
|
||||
search_space_id=search_space_id,
|
||||
thread_id=thread_id,
|
||||
report_group_id=report_group_id,
|
||||
)
|
||||
db_session.add(failed_report)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(failed_report)
|
||||
# If this is a new group (v1 failed), set group to self
|
||||
if not failed_report.report_group_id:
|
||||
failed_report.report_group_id = failed_report.id
|
||||
await db_session.commit()
|
||||
logger.info(
|
||||
f"[generate_report] Saved failed report {failed_report.id}: {error_msg}"
|
||||
)
|
||||
|
|
@ -169,10 +219,20 @@ def create_generate_report_tool(
|
|||
f"**Additional Instructions:** {user_instructions}"
|
||||
)
|
||||
|
||||
# If revising, include previous version content
|
||||
previous_version_section = ""
|
||||
if parent_report and parent_report.content:
|
||||
previous_version_section = (
|
||||
"**Previous Version of This Report (refine this based on the instructions above — "
|
||||
"preserve structure and quality, apply only the requested changes):**\n\n"
|
||||
f"{parent_report.content}"
|
||||
)
|
||||
|
||||
prompt = _REPORT_PROMPT.format(
|
||||
topic=topic,
|
||||
report_style=report_style,
|
||||
user_instructions_section=user_instructions_section,
|
||||
previous_version_section=previous_version_section,
|
||||
source_content=source_content[:100000], # Cap source content
|
||||
)
|
||||
|
||||
|
|
@ -203,13 +263,20 @@ def create_generate_report_tool(
|
|||
report_style=report_style,
|
||||
search_space_id=search_space_id,
|
||||
thread_id=thread_id,
|
||||
report_group_id=report_group_id, # None for v1, inherited for v2+
|
||||
)
|
||||
db_session.add(report)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(report)
|
||||
|
||||
# If this is a brand-new report (v1), set report_group_id = own id
|
||||
if not report.report_group_id:
|
||||
report.report_group_id = report.id
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[generate_report] Created report {report.id}: "
|
||||
f"[generate_report] Created report {report.id} "
|
||||
f"(group={report.report_group_id}): "
|
||||
f"{metadata.get('word_count', 0)} words, "
|
||||
f"{metadata.get('section_count', 0)} sections"
|
||||
)
|
||||
|
|
@ -235,4 +302,3 @@ def create_generate_report_tool(
|
|||
}
|
||||
|
||||
return generate_report
|
||||
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,10 @@ class Report(BaseModel, TimestampMixin):
|
|||
)
|
||||
search_space = relationship("SearchSpace", back_populates="reports")
|
||||
|
||||
# Versioning: reports sharing the same report_group_id are versions of the same report.
|
||||
# For v1, report_group_id = the report's own id (set after insert).
|
||||
report_group_id = Column(Integer, nullable=True, index=True)
|
||||
|
||||
thread_id = Column(
|
||||
Integer,
|
||||
ForeignKey("new_chat_threads.id", ondelete="SET NULL"),
|
||||
|
|
|
|||
|
|
@ -127,16 +127,25 @@ async def get_public_report_content(
|
|||
Get report content from a public chat snapshot.
|
||||
|
||||
No authentication required - the share_token provides access.
|
||||
Returns report content including title, markdown body, and metadata.
|
||||
Returns report content including title, markdown body, metadata, and versions.
|
||||
"""
|
||||
from app.services.public_chat_service import get_snapshot_report_versions
|
||||
|
||||
report_info = await get_snapshot_report(session, share_token, report_id)
|
||||
|
||||
if not report_info:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
# Get version siblings from the same snapshot
|
||||
versions = await get_snapshot_report_versions(
|
||||
session, share_token, report_info.get("report_group_id")
|
||||
)
|
||||
|
||||
return {
|
||||
"id": report_info.get("original_id"),
|
||||
"title": report_info.get("title"),
|
||||
"content": report_info.get("content"),
|
||||
"report_metadata": report_info.get("report_metadata"),
|
||||
"report_group_id": report_info.get("report_group_id"),
|
||||
"versions": versions,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
)
|
||||
from app.schemas import ReportContentRead, ReportRead
|
||||
from app.schemas.reports import ReportVersionInfo
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_search_space_access
|
||||
|
||||
|
|
@ -70,6 +71,24 @@ async def _get_report_with_access(
|
|||
return report
|
||||
|
||||
|
||||
async def _get_version_siblings(
|
||||
session: AsyncSession,
|
||||
report: Report,
|
||||
) -> list[ReportVersionInfo]:
|
||||
"""Get all versions in the same report group, ordered by created_at."""
|
||||
if not report.report_group_id:
|
||||
# Legacy report without group — it's the only version
|
||||
return [ReportVersionInfo(id=report.id, created_at=report.created_at)]
|
||||
|
||||
result = await session.execute(
|
||||
select(Report.id, Report.created_at)
|
||||
.filter(Report.report_group_id == report.report_group_id)
|
||||
.order_by(Report.created_at.asc())
|
||||
)
|
||||
rows = result.all()
|
||||
return [ReportVersionInfo(id=row[0], created_at=row[1]) for row in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -144,10 +163,20 @@ async def read_report_content(
|
|||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get full Markdown content of a report.
|
||||
Get full Markdown content of a report, including version siblings.
|
||||
"""
|
||||
try:
|
||||
return await _get_report_with_access(report_id, session, user)
|
||||
report = await _get_report_with_access(report_id, session, user)
|
||||
versions = await _get_version_siblings(session, report)
|
||||
|
||||
return ReportContentRead(
|
||||
id=report.id,
|
||||
title=report.title,
|
||||
content=report.content,
|
||||
report_metadata=report.report_metadata,
|
||||
report_group_id=report.report_group_id,
|
||||
versions=versions,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ from .new_llm_config import (
|
|||
NewLLMConfigUpdate,
|
||||
)
|
||||
from .podcasts import PodcastBase, PodcastCreate, PodcastRead, PodcastUpdate
|
||||
from .reports import ReportBase, ReportContentRead, ReportRead
|
||||
from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo
|
||||
from .rbac_schemas import (
|
||||
InviteAcceptRequest,
|
||||
InviteAcceptResponse,
|
||||
|
|
@ -190,6 +190,7 @@ __all__ = [
|
|||
"ReportBase",
|
||||
"ReportContentRead",
|
||||
"ReportRead",
|
||||
"ReportVersionInfo",
|
||||
"RoleCreate",
|
||||
"RoleRead",
|
||||
"RoleUpdate",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@ class ReportRead(BaseModel):
|
|||
title: str
|
||||
report_style: str | None = None
|
||||
report_metadata: dict[str, Any] | None = None
|
||||
report_group_id: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReportVersionInfo(BaseModel):
|
||||
"""Lightweight version entry for the version switcher UI."""
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
|
@ -35,7 +46,8 @@ class ReportContentRead(BaseModel):
|
|||
title: str
|
||||
content: str | None = None
|
||||
report_metadata: dict[str, Any] | None = None
|
||||
report_group_id: int | None = None
|
||||
versions: list[ReportVersionInfo] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
|
|
|||
|
|
@ -344,6 +344,8 @@ async def _get_report_for_snapshot(
|
|||
"title": report.title,
|
||||
"content": report.content,
|
||||
"report_metadata": report.report_metadata,
|
||||
"report_group_id": report.report_group_id,
|
||||
"created_at": report.created_at.isoformat() if report.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -715,6 +717,9 @@ async def clone_from_snapshot(
|
|||
)
|
||||
session.add(new_report)
|
||||
await session.flush()
|
||||
# For cloned reports, set report_group_id = own id
|
||||
# (each cloned report starts as its own v1)
|
||||
new_report.report_group_id = new_report.id
|
||||
report_id_mapping[old_report_id] = new_report.id
|
||||
|
||||
if old_report_id and old_report_id in report_id_mapping:
|
||||
|
|
@ -790,3 +795,35 @@ async def get_snapshot_report(
|
|||
return report
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_snapshot_report_versions(
|
||||
session: AsyncSession,
|
||||
share_token: str,
|
||||
report_group_id: int | None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get all report versions in the same group from a snapshot.
|
||||
|
||||
Returns a list of lightweight version entries (id + created_at)
|
||||
for the version switcher UI, sorted by original_id (insertion order).
|
||||
"""
|
||||
if not report_group_id:
|
||||
return []
|
||||
|
||||
snapshot = await get_snapshot_by_token(session, share_token)
|
||||
if not snapshot:
|
||||
return []
|
||||
|
||||
reports = snapshot.snapshot_data.get("reports", [])
|
||||
siblings = [
|
||||
r for r in reports if r.get("report_group_id") == report_group_id
|
||||
]
|
||||
|
||||
# Sort by original_id (ascending = insertion order ≈ created_at order)
|
||||
siblings.sort(key=lambda r: r.get("original_id", 0))
|
||||
|
||||
return [
|
||||
{"id": r.get("original_id"), "created_at": r.get("created_at")}
|
||||
for r in siblings
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ClipboardIcon,
|
||||
FileTextIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -32,6 +30,14 @@ import { useMediaQuery } from "@/hooks/use-media-query";
|
|||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
/**
|
||||
* Zod schema for a single version entry
|
||||
*/
|
||||
const VersionInfoSchema = z.object({
|
||||
id: z.number(),
|
||||
created_at: z.string().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for the report content API response
|
||||
*/
|
||||
|
|
@ -48,9 +54,12 @@ const ReportContentResponseSchema = z.object({
|
|||
section_count: z.number().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
report_group_id: z.number().nullish(),
|
||||
versions: z.array(VersionInfoSchema).nullish(),
|
||||
});
|
||||
|
||||
type ReportContentResponse = z.infer<typeof ReportContentResponseSchema>;
|
||||
type VersionInfo = z.infer<typeof VersionInfoSchema>;
|
||||
|
||||
/**
|
||||
* Shimmer loading skeleton for report panel
|
||||
|
|
@ -117,7 +126,16 @@ function ReportPanelContent({
|
|||
const [copied, setCopied] = useState(false);
|
||||
const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null);
|
||||
|
||||
// Fetch report content
|
||||
// Version state
|
||||
const [activeReportId, setActiveReportId] = useState(reportId);
|
||||
const [versions, setVersions] = useState<VersionInfo[]>([]);
|
||||
|
||||
// Reset active version when the external reportId changes (e.g. clicking a different card)
|
||||
useEffect(() => {
|
||||
setActiveReportId(reportId);
|
||||
}, [reportId]);
|
||||
|
||||
// Fetch report content (re-runs when activeReportId changes for version switching)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchContent = async () => {
|
||||
|
|
@ -125,8 +143,8 @@ function ReportPanelContent({
|
|||
setError(null);
|
||||
try {
|
||||
const url = shareToken
|
||||
? `/api/v1/public/${shareToken}/reports/${reportId}/content`
|
||||
: `/api/v1/reports/${reportId}/content`;
|
||||
? `/api/v1/public/${shareToken}/reports/${activeReportId}/content`
|
||||
: `/api/v1/reports/${activeReportId}/content`;
|
||||
const rawData = await baseApiService.get<unknown>(url);
|
||||
if (cancelled) return;
|
||||
const parsed = ReportContentResponseSchema.safeParse(rawData);
|
||||
|
|
@ -139,6 +157,10 @@ function ReportPanelContent({
|
|||
);
|
||||
} else {
|
||||
setReportContent(parsed.data);
|
||||
// Update versions from the response
|
||||
if (parsed.data.versions && parsed.data.versions.length > 0) {
|
||||
setVersions(parsed.data.versions);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
|
|
@ -162,7 +184,7 @@ function ReportPanelContent({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reportId, shareToken]);
|
||||
}, [activeReportId, shareToken]);
|
||||
|
||||
// Copy markdown content
|
||||
const handleCopy = useCallback(async () => {
|
||||
|
|
@ -200,7 +222,7 @@ function ReportPanelContent({
|
|||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/export?format=${format}`,
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
|
|
@ -224,7 +246,7 @@ function ReportPanelContent({
|
|||
setExporting(null);
|
||||
}
|
||||
},
|
||||
[reportId, title, reportContent?.content]
|
||||
[activeReportId, title, reportContent?.content]
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -248,35 +270,35 @@ function ReportPanelContent({
|
|||
);
|
||||
}
|
||||
|
||||
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-7 min-w-[80px] px-2.5 py-4 text-xs gap-1.5 rounded-r-none border-r-0"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 py-4 px-1.5 rounded-l-none"
|
||||
>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
<span className="sr-only">Download options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copy button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px]"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
|
||||
{/* Export dropdown */}
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
|
||||
>
|
||||
Export
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}>
|
||||
<DropdownMenuItem onClick={() => handleExport("md")}>
|
||||
Download Markdown
|
||||
|
|
@ -306,7 +328,32 @@ function ReportPanelContent({
|
|||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Version switcher — only shown when multiple versions exist */}
|
||||
{versions.length > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
|
||||
{versions.map((v, i) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={`px-2 py-0.5 rounded-md text-xs font-medium transition-colors ${
|
||||
v.id === activeReportId
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
v{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums ml-1">
|
||||
{activeVersionIndex + 1} of {versions.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button
|
||||
|
|
@ -430,4 +477,3 @@ export function ReportPanel() {
|
|||
|
||||
return <MobileReportDrawer />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import { Dot, FileTextIcon } from "lucide-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
|
@ -21,6 +21,7 @@ const GenerateReportArgsSchema = z.object({
|
|||
source_content: z.string(),
|
||||
report_style: z.string().nullish(),
|
||||
user_instructions: z.string().nullish(),
|
||||
parent_report_id: z.number().nullish(),
|
||||
});
|
||||
|
||||
const GenerateReportResultSchema = z.object({
|
||||
|
|
@ -43,6 +44,15 @@ const ReportMetadataResponseSchema = z.object({
|
|||
section_count: z.number().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
report_group_id: z.number().nullish(),
|
||||
versions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
created_at: z.string().nullish(),
|
||||
})
|
||||
)
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -117,11 +127,12 @@ function ReportCard({
|
|||
const [metadata, setMetadata] = useState<{
|
||||
title: string;
|
||||
wordCount: number | null;
|
||||
}>({ title, wordCount: wordCount ?? null });
|
||||
versionLabel: string | null;
|
||||
}>({ title, wordCount: wordCount ?? null, versionLabel: null });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch lightweight metadata (title + counts only, no content)
|
||||
// Fetch lightweight metadata (title + counts + version info)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchMetadata = async () => {
|
||||
|
|
@ -142,10 +153,20 @@ function ReportCard({
|
|||
"Report generation failed"
|
||||
);
|
||||
} else {
|
||||
// Determine version label from versions array
|
||||
let versionLabel: string | null = null;
|
||||
const versions = parsed.data.versions;
|
||||
if (versions && versions.length > 1) {
|
||||
const idx = versions.findIndex((v) => v.id === reportId);
|
||||
if (idx >= 0) {
|
||||
versionLabel = `version ${idx + 1}`;
|
||||
}
|
||||
}
|
||||
setMetadata({
|
||||
title: parsed.data.title || title,
|
||||
wordCount:
|
||||
parsed.data.report_metadata?.word_count ?? wordCount ?? null,
|
||||
versionLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -200,6 +221,8 @@ function ReportCard({
|
|||
<>
|
||||
{metadata.wordCount != null &&
|
||||
`${metadata.wordCount.toLocaleString()} words`}
|
||||
{metadata.wordCount != null && metadata.versionLabel && <Dot className="inline size-4" />}
|
||||
{metadata.versionLabel}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -284,4 +307,3 @@ export const GenerateReportToolUI = makeAssistantToolUI<
|
|||
return <ReportErrorState title={topic} error="Missing report ID" />;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue