diff --git a/application/agents/tools/wiki.py b/application/agents/tools/wiki.py index f8c009a9..7378cf8b 100644 --- a/application/agents/tools/wiki.py +++ b/application/agents/tools/wiki.py @@ -15,6 +15,8 @@ logger = logging.getLogger(__name__) WIKI_TOOL_ID = "wiki" +WIKI_UPDATED_VIA_AGENT = "agent" + MAX_WIKI_PAGE_BYTES = 1_000_000 @@ -313,6 +315,7 @@ class WikiTool(Tool): validated_path, content, updated_by=self.updated_by, + updated_via=WIKI_UPDATED_VIA_AGENT, expected_version=( existing.get("version") if existing else None ), @@ -357,6 +360,7 @@ class WikiTool(Tool): updated, title=page.get("title"), updated_by=self.updated_by, + updated_via=WIKI_UPDATED_VIA_AGENT, expected_version=page.get("version"), ) except WikiPageConflict: @@ -395,6 +399,7 @@ class WikiTool(Tool): updated, title=page.get("title"), updated_by=self.updated_by, + updated_via=WIKI_UPDATED_VIA_AGENT, expected_version=page.get("version"), ) except WikiPageConflict: diff --git a/application/alembic/versions/0024_wiki_pages_updated_via.py b/application/alembic/versions/0024_wiki_pages_updated_via.py new file mode 100644 index 00000000..7ad14604 --- /dev/null +++ b/application/alembic/versions/0024_wiki_pages_updated_via.py @@ -0,0 +1,28 @@ +"""0024 wiki pages updated_via — provenance of the last page write. + +Adds a nullable ``updated_via`` column recording which channel last wrote a +page ("agent" via the WikiTool/conversion, "human" via the edit endpoint) so the +viewer can stamp each page's provenance alongside ``updated_by`` / ``updated_at`` +/ ``version``. + +Revision ID: 0024_wiki_pages_updated_via +Revises: 0023_wiki_pages +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0024_wiki_pages_updated_via" +down_revision: Union[str, None] = "0023_wiki_pages" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS updated_via TEXT;") + + +def downgrade() -> None: + op.execute("ALTER TABLE wiki_pages DROP COLUMN IF EXISTS updated_via;") diff --git a/application/api/user/sources/routes.py b/application/api/user/sources/routes.py index a8449bc2..d5d98b0d 100644 --- a/application/api/user/sources/routes.py +++ b/application/api/user/sources/routes.py @@ -697,6 +697,7 @@ def _wiki_page_node(page): "version": page.get("version"), "embed_status": page.get("embed_status"), "updated_by": page.get("updated_by"), + "updated_via": page.get("updated_via"), "updated_at": ( page["updated_at"].isoformat() if page.get("updated_at") is not None @@ -769,6 +770,7 @@ class CreateWikiSource(Resource): WIKI_INDEX_PATH, initial_content, updated_by=user, + updated_via="human", ) rebuild_wiki_directory_structure(conn, source_id, user) except Exception as err: @@ -922,6 +924,7 @@ class WikiPage(Resource): path, content, updated_by=user, + updated_via="human", expected_version=expected_version, ) except WikiPageConflict: diff --git a/application/storage/db/models.py b/application/storage/db/models.py index 2f2d3f56..50df6bcb 100644 --- a/application/storage/db/models.py +++ b/application/storage/db/models.py @@ -431,6 +431,7 @@ wiki_pages_table = Table( Column("content_hash", Text), Column("embed_status", Text, nullable=False, server_default="pending"), Column("updated_by", Text), + Column("updated_via", Text), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), UniqueConstraint("source_id", "path", name="wiki_pages_source_path_uidx"), diff --git a/application/storage/db/repositories/wiki_pages.py b/application/storage/db/repositories/wiki_pages.py index 47db1996..fcb07233 100644 --- a/application/storage/db/repositories/wiki_pages.py +++ b/application/storage/db/repositories/wiki_pages.py @@ -37,6 +37,7 @@ class WikiPagesRepository: content: str, title: Optional[str] = None, updated_by: Optional[str] = None, + updated_via: Optional[str] = None, expected_version: Optional[int] = None, ) -> dict: """Create or overwrite a page. @@ -67,6 +68,7 @@ class WikiPagesRepository: token_count = :token_count, content_hash = :content_hash, updated_by = :updated_by, + updated_via = :updated_via, version = version + 1, embed_status = 'pending', updated_at = now() @@ -84,6 +86,7 @@ class WikiPagesRepository: "token_count": num_tokens_from_string(content), "content_hash": content_hash, "updated_by": updated_by, + "updated_via": updated_via, "expected_version": expected_version, }, ) @@ -100,10 +103,10 @@ class WikiPagesRepository: """ INSERT INTO wiki_pages (source_id, path, title, content, token_count, - content_hash, updated_by, embed_status) + content_hash, updated_by, updated_via, embed_status) VALUES (CAST(:source_id AS uuid), :path, :title, :content, :token_count, - :content_hash, :updated_by, 'pending') + :content_hash, :updated_by, :updated_via, 'pending') ON CONFLICT (source_id, path) DO UPDATE SET title = EXCLUDED.title, @@ -111,6 +114,7 @@ class WikiPagesRepository: token_count = EXCLUDED.token_count, content_hash = EXCLUDED.content_hash, updated_by = EXCLUDED.updated_by, + updated_via = EXCLUDED.updated_via, version = wiki_pages.version + 1, embed_status = 'pending', updated_at = now() @@ -125,6 +129,7 @@ class WikiPagesRepository: "token_count": num_tokens_from_string(content), "content_hash": content_hash, "updated_by": updated_by, + "updated_via": updated_via, }, ) return row_to_dict(result.fetchone()) diff --git a/application/worker.py b/application/worker.py index 0a8bc98e..17962460 100755 --- a/application/worker.py +++ b/application/worker.py @@ -2158,7 +2158,12 @@ def convert_source_to_wiki_worker(self, source_id, user): rel_path ) repo.upsert( - source_id, page_path, content, title=title, updated_by=user + source_id, + page_path, + content, + title=title, + updated_by=user, + updated_via="agent", ) created_pages.append((page_path, _content_hash(content))) diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index c78fb433..98ef4d74 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -115,6 +115,12 @@ const userService = { token: string | null, ): Promise => throttledApiClient.get(endpoints.USER.WIKI_PAGE(sourceId, path), token), + updateWikiPage: ( + sourceId: string, + data: { path: string; content: string; expected_version?: number }, + token: string | null, + ): Promise => + apiClient.put(endpoints.USER.WIKI_PAGE(sourceId, data.path), data, token), getAvailableTools: (token: string | null): Promise => apiClient.get(endpoints.USER.GET_AVAILABLE_TOOLS, token), getUserTools: (token: string | null): Promise => diff --git a/frontend/src/components/WikiViewer.tsx b/frontend/src/components/WikiViewer.tsx index 8633d847..64f1e63e 100644 --- a/frontend/src/components/WikiViewer.tsx +++ b/frontend/src/components/WikiViewer.tsx @@ -7,36 +7,83 @@ import remarkGfm from 'remark-gfm'; import userService from '../api/services/userService'; import ArrowLeft from '../assets/arrow-left.svg'; import { selectToken } from '../preferences/preferenceSlice'; +import { decodeJwtPayload } from '../utils/jwtUtils'; import { Button } from './ui/button'; import SkeletonLoader from './SkeletonLoader'; - -interface WikiPageNode { - path: string; - title?: string | null; - token_count?: number; - embed_status?: string; -} +import { + WikiPageNode, + formatRelativeTime, + provenanceKey, + saveWikiPage, +} from './wikiViewerUtils'; interface WikiViewerProps { docId: string; sourceName: string; + canEdit?: boolean; onBackToDocuments: () => void; } +const markdownComponents = { + h1: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + a: ({ children, href }: { children?: React.ReactNode; href?: string }) => ( + + {children} + + ), + code: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), +}; + const WikiViewer: React.FC = ({ docId, sourceName, + canEdit = false, onBackToDocuments, }) => { const { t } = useTranslation(); const token = useSelector(selectToken); + const currentUserSub = token + ? ((decodeJwtPayload(token)?.sub as string | undefined) ?? null) + : null; const [pages, setPages] = useState([]); const [selectedPath, setSelectedPath] = useState(null); + const [page, setPage] = useState(null); const [content, setContent] = useState(''); const [loadingPages, setLoadingPages] = useState(true); const [loadingContent, setLoadingContent] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(''); + const [saving, setSaving] = useState(false); + const [editError, setEditError] = useState(null); + useEffect(() => { let cancelled = false; setLoadingPages(true); @@ -59,7 +106,10 @@ const WikiViewer: React.FC = ({ }, [docId, token]); useEffect(() => { + setIsEditing(false); + setEditError(null); if (!selectedPath) { + setPage(null); setContent(''); return; } @@ -70,7 +120,9 @@ const WikiViewer: React.FC = ({ .then((response) => response.json()) .then((data) => { if (cancelled) return; - setContent(data?.page?.content ?? ''); + const node: WikiPageNode | null = data?.page ?? null; + setPage(node); + setContent((data?.page?.content as string) ?? ''); }) .catch((error) => console.error('Error loading wiki page:', error)) .finally(() => { @@ -81,6 +133,76 @@ const WikiViewer: React.FC = ({ }; }, [docId, selectedPath, token]); + const startEditing = () => { + setDraft(content); + setEditError(null); + setIsEditing(true); + }; + + const cancelEditing = () => { + setIsEditing(false); + setEditError(null); + }; + + const handleSave = async () => { + if (!selectedPath) return; + setSaving(true); + setEditError(null); + try { + const outcome = await saveWikiPage( + userService, + docId, + selectedPath, + draft, + page?.version, + token, + ); + switch (outcome.status) { + case 'saved': + if (outcome.page) setPage(outcome.page); + setContent(draft); + setIsEditing(false); + break; + case 'conflict': + if (outcome.page) { + setPage(outcome.page); + setContent(outcome.page.content ?? ''); + } + setEditError(t('settings.sources.wiki.conflict')); + break; + case 'forbidden': + setEditError(t('settings.sources.wiki.forbidden')); + break; + default: + setEditError(t('settings.sources.wiki.saveFailed')); + } + } catch (error) { + console.error('Error saving wiki page:', error); + setEditError(t('settings.sources.wiki.saveFailed')); + } finally { + setSaving(false); + } + }; + + const provenanceLabel = (via?: string | null, by?: string | null): string => + t(`settings.sources.wiki.stamp.${provenanceKey(via, by, currentUserSub)}`); + + const renderStamp = () => { + if (!page) return null; + const who = provenanceLabel(page.updated_via, page.updated_by); + const when = formatRelativeTime(page.updated_at); + const parts = [t('settings.sources.wiki.stamp.editedBy', { who })]; + if (when) parts.push(when); + if (page.version != null) { + parts.push( + t('settings.sources.wiki.stamp.version', { version: page.version }), + ); + } + return ( +

{parts.join(' · ')}

+ ); + }; + return (
@@ -109,19 +231,19 @@ const WikiViewer: React.FC = ({

) : (