-
Notifications
You must be signed in to change notification settings - Fork 0
feat(wiki): index ページ・活動ログ・stale 検出を追加 (P4, #598) #604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
8c413a4
37948e4
cbcd9a3
23e50b9
4795070
a987f79
56df41e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { adminFetch } from "./client"; | ||
|
|
||
| /** | ||
| * 活動ログの種別。 | ||
| * Activity kind enumeration. | ||
| */ | ||
| export type ActivityKind = | ||
| | "clip_ingest" | ||
| | "chat_promote" | ||
| | "lint_run" | ||
| | "wiki_generate" | ||
| | "index_build" | ||
| | "wiki_schema_update"; | ||
|
|
||
| /** | ||
| * 活動ログの起点(user / ai / system)。 | ||
| * Activity actor. | ||
| */ | ||
| export type ActivityActor = "user" | "ai" | "system"; | ||
|
|
||
| /** | ||
| * 1 件の活動ログ。 | ||
| * A single activity entry. | ||
| */ | ||
| export interface ActivityEntry { | ||
| id: string; | ||
| kind: ActivityKind; | ||
| actor: ActivityActor; | ||
| target_page_ids: string[]; | ||
| detail: Record<string, unknown> | null; | ||
| created_at: string; | ||
| } | ||
|
|
||
| /** | ||
| * 活動ログ取得レスポンス。 | ||
| * Response from GET /api/activity. | ||
| */ | ||
| export interface ActivityListResponse { | ||
| entries: ActivityEntry[]; | ||
| total: number; | ||
| limit: number; | ||
| } | ||
|
|
||
| /** | ||
| * 一覧クエリパラメータ。 | ||
| * List query parameters. | ||
| */ | ||
| export interface ActivityListParams { | ||
| kind?: ActivityKind; | ||
| actor?: ActivityActor; | ||
| from?: string; | ||
| to?: string; | ||
| limit?: number; | ||
| offset?: number; | ||
| } | ||
|
|
||
| async function getErrorMessage(res: Response, fallback: string): Promise<string> { | ||
| const err = await res.json().catch(() => ({ message: res.statusText })); | ||
| return (err as { message?: string }).message ?? fallback; | ||
| } | ||
|
|
||
| /** | ||
| * 自分の活動ログを取得する。 | ||
| * Fetches the current user's activity entries. | ||
| * | ||
| * @param params - フィルタ・ページング / Filters and paging | ||
| */ | ||
| export async function listActivity(params: ActivityListParams = {}): Promise<ActivityListResponse> { | ||
| const query = new URLSearchParams(); | ||
| if (params.kind) query.set("kind", params.kind); | ||
| if (params.actor) query.set("actor", params.actor); | ||
| if (params.from) query.set("from", params.from); | ||
| if (params.to) query.set("to", params.to); | ||
| if (typeof params.limit === "number") query.set("limit", String(params.limit)); | ||
| if (typeof params.offset === "number") query.set("offset", String(params.offset)); | ||
| const qs = query.toString(); | ||
| const path = qs ? `/api/activity?${qs}` : "/api/activity"; | ||
|
|
||
| const res = await adminFetch(path); | ||
| if (!res.ok) { | ||
| throw new Error(await getErrorMessage(res, "Failed to fetch activity entries")); | ||
| } | ||
| return res.json(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { | ||
| Badge, | ||
| Button, | ||
| Select, | ||
| SelectContent, | ||
| SelectItem, | ||
| SelectTrigger, | ||
| SelectValue, | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from "@zedi/ui"; | ||
| import { | ||
| listActivity, | ||
| type ActivityActor, | ||
| type ActivityEntry, | ||
| type ActivityKind, | ||
| } from "@/api/activity"; | ||
| import { formatDate } from "@/lib/dateUtils"; | ||
|
|
||
| /** | ||
| * ルール/起点のラベルマップ。 | ||
| * Labels for activity kind and actor. | ||
| */ | ||
| const KIND_LABELS: Record<ActivityKind, string> = { | ||
| clip_ingest: "クリップ取り込み / Clip ingest", | ||
| chat_promote: "Chat → Wiki 昇格 / Chat promote", | ||
| lint_run: "Lint 実行 / Lint run", | ||
| wiki_generate: "Wiki 生成 / Wiki generate", | ||
| index_build: "Index 構築 / Index build", | ||
| wiki_schema_update: "スキーマ更新 / Schema update", | ||
| }; | ||
| const ACTOR_LABELS: Record<ActivityActor, string> = { | ||
| user: "ユーザー / User", | ||
| ai: "AI", | ||
| system: "システム / System", | ||
| }; | ||
|
|
||
| const ANY = "__any__"; | ||
| const KINDS: ActivityKind[] = [ | ||
| "clip_ingest", | ||
| "chat_promote", | ||
| "lint_run", | ||
| "wiki_generate", | ||
| "index_build", | ||
| "wiki_schema_update", | ||
| ]; | ||
| const ACTORS: ActivityActor[] = ["user", "ai", "system"]; | ||
|
|
||
| /** | ||
| * detail JSON からサマリ文字列を組み立てる。 | ||
| * Builds a human-readable summary from a detail payload. | ||
| */ | ||
| function formatDetail(entry: ActivityEntry): string { | ||
| const detail = entry.detail ?? {}; | ||
| if (entry.kind === "lint_run" && typeof detail.total === "number") { | ||
| return `${detail.total} findings`; | ||
| } | ||
| if (entry.kind === "index_build") { | ||
| const total = typeof detail.totalPages === "number" ? detail.totalPages : "?"; | ||
| const cats = typeof detail.categoryCount === "number" ? detail.categoryCount : "?"; | ||
| return `${total} pages / ${cats} categories`; | ||
| } | ||
| if (entry.kind === "clip_ingest" || entry.kind === "chat_promote") { | ||
| const title = typeof detail.title === "string" ? detail.title : null; | ||
| const url = typeof detail.url === "string" ? detail.url : null; | ||
| return [title, url].filter(Boolean).join(" — ") || "—"; | ||
| } | ||
| if (entry.kind === "wiki_schema_update") { | ||
| const len = typeof detail.contentLength === "number" ? detail.contentLength : "?"; | ||
| return `content: ${len} chars`; | ||
| } | ||
| return JSON.stringify(detail); | ||
| } | ||
|
|
||
| /** | ||
| * 管理画面「活動ログ」ページ。`__index__` 再構築ボタンも備える。 | ||
| * Admin Activity Log page. | ||
| */ | ||
| export default function ActivityLog() { | ||
| const [entries, setEntries] = useState<ActivityEntry[]>([]); | ||
| const [total, setTotal] = useState(0); | ||
| const [loading, setLoading] = useState(true); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [kindFilter, setKindFilter] = useState<ActivityKind | undefined>(undefined); | ||
| const [actorFilter, setActorFilter] = useState<ActivityActor | undefined>(undefined); | ||
|
|
||
| const mountedRef = useRef(true); | ||
|
|
||
| const load = useCallback(async () => { | ||
| if (mountedRef.current) setLoading(true); | ||
| if (mountedRef.current) setError(null); | ||
| try { | ||
| const result = await listActivity({ kind: kindFilter, actor: actorFilter, limit: 100 }); | ||
| if (!mountedRef.current) return; | ||
| setEntries(result.entries); | ||
| setTotal(result.total); | ||
| } catch (e) { | ||
| if (!mountedRef.current) return; | ||
| setError(e instanceof Error ? e.message : String(e)); | ||
| } finally { | ||
| if (mountedRef.current) setLoading(false); | ||
| } | ||
| }, [kindFilter, actorFilter]); | ||
|
|
||
| useEffect(() => { | ||
| mountedRef.current = true; | ||
| void load(); | ||
| return () => { | ||
| mountedRef.current = false; | ||
| }; | ||
| }, [load]); | ||
|
|
||
| const onKind = (v: string) => setKindFilter(v === ANY ? undefined : (v as ActivityKind)); | ||
| const onActor = (v: string) => setActorFilter(v === ANY ? undefined : (v as ActivityActor)); | ||
|
|
||
| return ( | ||
| <div> | ||
| <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> | ||
| <h1 className="text-lg font-semibold">Activity Log / 活動ログ</h1> | ||
| <Button type="button" onClick={() => void load()} disabled={loading}> | ||
| {loading ? "読み込み中..." : "再読み込み"} | ||
| </Button> | ||
| </div> | ||
|
|
||
| <div className="mt-4 flex flex-wrap items-end gap-4"> | ||
| <div> | ||
| <label htmlFor="activity-kind" className="text-muted-foreground mb-1 block text-xs"> | ||
| 種別 / Kind | ||
| </label> | ||
| <Select value={kindFilter ?? ANY} onValueChange={onKind}> | ||
| <SelectTrigger id="activity-kind" className="w-60"> | ||
| <SelectValue /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value={ANY}>すべて</SelectItem> | ||
| {KINDS.map((k) => ( | ||
| <SelectItem key={k} value={k}> | ||
| {KIND_LABELS[k]} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| </div> | ||
| <div> | ||
| <label htmlFor="activity-actor" className="text-muted-foreground mb-1 block text-xs"> | ||
| 起点 / Actor | ||
| </label> | ||
| <Select value={actorFilter ?? ANY} onValueChange={onActor}> | ||
| <SelectTrigger id="activity-actor" className="w-48"> | ||
| <SelectValue /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value={ANY}>すべて</SelectItem> | ||
| {ACTORS.map((a) => ( | ||
| <SelectItem key={a} value={a}> | ||
| {ACTOR_LABELS[a]} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| </div> | ||
| </div> | ||
|
|
||
| {error && ( | ||
| <div className="mt-2 rounded bg-red-900/30 px-3 py-2 text-sm text-red-200">{error}</div> | ||
| )} | ||
|
|
||
| {loading && entries.length === 0 ? ( | ||
| <p className="text-muted-foreground mt-4">読み込み中...</p> | ||
| ) : entries.length === 0 ? ( | ||
| <p className="text-muted-foreground mt-4">活動ログはまだありません。</p> | ||
| ) : ( | ||
| <div className="mt-4 overflow-x-auto"> | ||
| <Table className="border-border min-w-[720px] rounded border"> | ||
| <TableHeader> | ||
| <TableRow className="border-border bg-muted/50 hover:bg-transparent"> | ||
| <TableHead className="px-3 py-2">種別</TableHead> | ||
| <TableHead className="px-3 py-2">起点</TableHead> | ||
| <TableHead className="px-3 py-2">詳細</TableHead> | ||
| <TableHead className="px-3 py-2">関連ページ</TableHead> | ||
| <TableHead className="px-3 py-2">記録日時</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {entries.map((entry) => ( | ||
| <TableRow key={entry.id} className="border-border align-top"> | ||
| <TableCell className="px-3 py-2"> | ||
| <Badge variant="outline">{KIND_LABELS[entry.kind]}</Badge> | ||
| </TableCell> | ||
| <TableCell className="px-3 py-2"> | ||
| <Badge variant="secondary">{ACTOR_LABELS[entry.actor]}</Badge> | ||
| </TableCell> | ||
| <TableCell className="max-w-md px-3 py-2 text-sm break-words"> | ||
| {formatDetail(entry)} | ||
| </TableCell> | ||
| <TableCell className="text-muted-foreground px-3 py-2 text-xs"> | ||
| {entry.target_page_ids.length} | ||
| </TableCell> | ||
| <TableCell className="text-muted-foreground px-3 py-2 whitespace-nowrap"> | ||
| {formatDate(entry.created_at)} | ||
| </TableCell> | ||
| </TableRow> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| <p className="text-muted-foreground mt-2 text-xs"> | ||
| 表示 {entries.length} / 合計 {total} 件 | ||
| </p> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ const RULE_LABELS: Record<LintRule, string> = { | |
| title_similar: "タイトル類似 / Title Similar", | ||
| conflict: "矛盾 / Conflict", | ||
| broken_link: "リンク切れ / Broken Link", | ||
| stale: "古い情報 / Stale", | ||
| }; | ||
|
Comment on lines
+23
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if stale rule exists in LintRule type definition
rg -n "stale" admin/src/api/lint.ts server/api/src/Repository: otomatty/zedi Length of output: 2689 🏁 Script executed: # Check LintRule type definition in admin/src/api/lint.ts
cat admin/src/api/lint.tsRepository: otomatty/zedi Length of output: 2256 🏁 Script executed: # Read WikiHealthContent.tsx to see exact RULE_LABELS content
sed -n '23,29p' admin/src/pages/wiki-health/WikiHealthContent.tsxRepository: otomatty/zedi Length of output: 291 Update The server-side implementation includes a 🤖 Prompt for AI Agents
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 現コードを確認したところ、 Verified in the current branch: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
差分の表示が 🧠 Learnings used |
||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| -- Add `activity_log` table for Wiki-level actions (P4, otomatty/zedi#598). | ||
| -- Wiki レベルの行動履歴(ingest / chat 昇格 / lint 実行 / wiki 生成等)を記録する | ||
| -- append-only ログ。`ai_usage_logs`(課金)や `admin_audit_logs`(管理者監査) | ||
| -- とは用途が異なり、ユーザーの Wiki がどう育ったかを時系列で可視化する。 | ||
| -- | ||
| -- See parent epic otomatty/zedi#594, sub-issue #598. | ||
|
|
||
| CREATE TABLE "activity_log" ( | ||
| "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | ||
| "owner_id" text NOT NULL, | ||
| "kind" text NOT NULL, | ||
| "actor" text NOT NULL, | ||
| "target_page_ids" text[] DEFAULT '{}' NOT NULL, | ||
| "detail" jsonb, | ||
| "created_at" timestamp with time zone DEFAULT now() NOT NULL | ||
| ); | ||
| --> statement-breakpoint | ||
| ALTER TABLE "activity_log" | ||
| ADD CONSTRAINT "activity_log_owner_id_user_id_fk" | ||
| FOREIGN KEY ("owner_id") REFERENCES "user"("id") ON DELETE cascade ON UPDATE no action; | ||
| --> statement-breakpoint | ||
| CREATE INDEX "idx_activity_log_owner_created" ON "activity_log" ("owner_id", "created_at" DESC); | ||
| --> statement-breakpoint | ||
| CREATE INDEX "idx_activity_log_owner_kind_created" | ||
| ON "activity_log" ("owner_id", "kind", "created_at" DESC); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| -- Add `special_kind` column to `pages` for Wiki "index" / "log" pseudo-pages | ||
| -- (P4, otomatty/zedi#598). | ||
| -- Karpathy LLM Wiki パターンの `__index__` / `__log__` 相当の特殊ページを | ||
| -- `pages` テーブル内で表現するためのカラム。NULL は通常ページ。 | ||
|
|
||
| ALTER TABLE "pages" ADD COLUMN "special_kind" text; | ||
| --> statement-breakpoint | ||
| CREATE INDEX "idx_pages_owner_special_kind" ON "pages" ("owner_id", "special_kind"); | ||
| --> statement-breakpoint | ||
| -- 部分ユニークインデックス: オーナーごとに各 special_kind は最大 1 行(非削除のもの)。 | ||
| -- Partial unique: at most one row per (owner_id, special_kind) among non-deleted pages. | ||
| CREATE UNIQUE INDEX "idx_pages_unique_special_kind_per_owner" | ||
| ON "pages" ("owner_id", "special_kind") | ||
| WHERE "special_kind" IS NOT NULL AND "is_deleted" = false; |
Uh oh!
There was an error while loading. Please reload this page.