Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sixty-clocks-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": patch
---

feat: implement native wiki visualizer for openwiki
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,28 @@ URL in Slack. If you have a fixed ngrok domain, run
ignore that HTTPS override and keep using the local loopback callback,
`http://127.0.0.1:53682/callback`.

Visualize a generated wiki as an interactive graph with a live docs reader:

```sh
openwiki visualize
```

This serves the wiki in `./openwiki` on a local loopback address
(`127.0.0.1`, never exposed on the network) and opens your browser to an
interactive node graph backed by a live-reloading markdown reader; edits to the
wiki files are picked up automatically while the server runs. Pass a path to
visualize a different wiki directory, `--port <port>` to choose the port (it
increments on conflict; default `4321`), and `--no-open` to leave the browser
alone:

```sh
openwiki visualize openwiki --port 4400 --no-open
```

The page loads its graph, markdown, and diagram libraries from a public CDN, so
an internet connection is required even though the server itself is local. Press
Ctrl-C to stop it.

Bare `openwiki` runs in code mode for the current repository. It creates initial repository documentation in `openwiki/` when no wiki exists. Use `openwiki personal` for the local general-purpose wiki in `~/.openwiki/wiki/`. By default, the CLI stays open after each run so you can send follow-up messages. Use `-p` or `--print` for a one-shot non-interactive run that prints the final assistant output.

Bare `openwiki --init` and `openwiki --update` default to code mode and operate on repository documentation. Use the `personal` positional mode or `--mode personal` to initialize or update the local personal brain wiki.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
],
"scripts": {
"openwiki": "node dist/cli.js",
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
"changeset:version": "changeset version",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"coverage": "vitest run --coverage",
Expand All @@ -49,7 +49,7 @@
"release": "pnpm run build && changeset publish",
"start": "node dist/cli.js",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json"
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json"
},
"dependencies": {
"@anthropic-ai/vertex-sdk": "^0.19.0",
Expand Down
24 changes: 24 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import path from "node:path";
import React, { useEffect, useRef, useState } from "react";
import { Box, render, Text, useApp, useInput } from "ink";
import { marked, type Token, type Tokens } from "marked";
Expand All @@ -8,6 +9,7 @@ import {
shouldDiscoverToolsAfterAuth,
} from "./auth/configure.js";
import { startNgrokTunnel } from "./auth/ngrok.js";
import { runVisualizeServer } from "./visualize/server.js";
import { formatAuthProviderList, runOAuthAuth } from "./auth/oauth.js";
import { ensureCodeModeRepoSetup, runCodeModeConnectors } from "./code-mode.js";
import {
Expand Down Expand Up @@ -3745,6 +3747,8 @@ if (command.kind === "auth") {
await runCronCommand(command);
} else if (command.kind === "ingest") {
await runIngestCommand(command);
} else if (command.kind === "visualize") {
await runVisualizeCommand(command);
} else if (shouldPrintStartupError(argv, parsedCommand, command)) {
process.stderr.write(`${command.message}\n`);
process.exitCode = command.exitCode;
Expand Down Expand Up @@ -3781,6 +3785,26 @@ async function runNgrokCommand(
}
}

/**
* Start the wiki visualizer server for a resolved wiki directory. Blocks until the
* server is stopped with Ctrl-C; surfaces a missing-directory error cleanly.
*/
async function runVisualizeCommand(
command: Extract<CliCommand, { kind: "visualize" }>,
): Promise<void> {
const wikiRoot = path.resolve(process.cwd(), command.wikiDir);
try {
await runVisualizeServer({
wikiRoot,
port: command.port,
open: command.open,
});
} catch (error) {
process.stderr.write(`${getErrorMessage(error)}\n`);
process.exitCode = 1;
}
}

async function runCronCommand(
command: Extract<CliCommand, { kind: "cron" }>,
): Promise<void> {
Expand Down
82 changes: 82 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export type CliCommand =
port: number;
url: string | null;
}
| {
kind: "visualize";
exitCode: 0;
wikiDir: string;
port: number;
open: boolean;
}
| {
kind: "ingest";
exitCode: 0;
Expand Down Expand Up @@ -206,6 +213,64 @@ export function parseCommand(argv: string[]): CliCommand {
};
}

if (argv[0] === "visualize") {
let wikiDir = "openwiki";
let port = 4321;
let open = true;
let sawPositional = false;
const optionArgs = argv.slice(1);

for (let index = 0; index < optionArgs.length; index += 1) {
const arg = optionArgs[index];

if (arg === "--no-open") {
open = false;
continue;
}

if (arg === "--port") {
const rawPort = optionArgs[index + 1];
if (!rawPort || rawPort.startsWith("-")) {
return {
kind: "error",
exitCode: 1,
message: "--port requires a value.",
};
}
port = Number(rawPort);
index += 1;
continue;
}

if (arg.startsWith("--port=")) {
port = Number(arg.slice("--port=".length));
continue;
}

if (!arg.startsWith("-") && !sawPositional) {
wikiDir = arg;
sawPositional = true;
continue;
}

return {
kind: "error",
exitCode: 1,
message: `Unknown option for visualize: ${arg}`,
};
}

if (!Number.isInteger(port) || port < 1024 || port > 65535) {
return {
kind: "error",
exitCode: 1,
message: "--port must be between 1024 and 65535.",
};
}

return { kind: "visualize", exitCode: 0, wikiDir, port, open };
}

if (argv[0] === "ingest") {
const target = parseIngestionTarget(argv[1] ?? "all");
if (!target) {
Expand Down Expand Up @@ -683,6 +748,7 @@ export const helpContent: HelpContent = {
"openwiki cron resume all",
"openwiki cron delete all",
"openwiki ngrok start [url] [--port <port>]",
"openwiki visualize [path] [--port <port>] [--no-open]",
],
commands: [
{
Expand Down Expand Up @@ -743,6 +809,11 @@ export const helpContent: HelpContent = {
description:
"Start an ngrok tunnel for Slack OAuth, optionally using a fixed HTTPS URL.",
},
{
label: "openwiki visualize [path]",
description:
"Serve an interactive graph and live docs reader for a generated wiki (defaults to ./openwiki).",
},
],
options: [
{
Expand Down Expand Up @@ -788,6 +859,15 @@ export const helpContent: HelpContent = {
description:
"Write the exact anonymous telemetry payload to a local JSON file.",
},
{
label: "--port <port>",
description:
"For visualize: port to serve on (default 4321; increments on conflict).",
},
{
label: "--no-open",
description: "For visualize: do not open the browser automatically.",
},
],
developmentOptions: [
{
Expand Down Expand Up @@ -821,6 +901,8 @@ export const helpContent: HelpContent = {
"openwiki auth tools notion",
"openwiki ngrok start",
"openwiki ngrok start https://openwiki.ngrok.app",
"openwiki visualize",
"openwiki visualize openwiki --port 4400 --no-open",
],
developmentExamples: ["openwiki --dry-run"],
};
Expand Down
156 changes: 156 additions & 0 deletions src/visualize/client-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { WikiGraph } from "./graph.js";

/**
* The minimal node shape the search/type filter needs. Both the raw wiki nodes
* from /api/graph and the force-graph render objects satisfy it structurally.
*/
export interface FilterableNode {
/**
* Stable page id (path relative to the wiki root, without .md).
*/
id: string;

/**
* Display title.
*/
title: string;

/**
* Page kind, matched against the active type filter.
*/
type: string;

/**
* Topic tags, folded into the free-text search haystack.
*
* @default undefined - treated as no tags.
*/
tags?: readonly string[];
}

/**
* Node sphere colors, keyed by draw order. Saturated enough to hold their hue as
* lit 3D spheres (pale pastels blow out to white under the scene lighting); the
* legend swatches reuse these same values.
*/
export const PALETTE: readonly string[] = [
"#4FA8F0",
"#B6DE3E",
"#D96FA6",
"#A97FE0",
"#D98A6B",
"#3FBFA0",
"#E0A63E",
"#6E8FF0",
];

/**
* HTML-escape the five characters that could break out of text or an attribute
* value, so wiki-sourced strings are safe to assign to innerHTML.
*/
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
};

/**
* Escape the HTML-significant characters in a string before it is inserted into
* the DOM. This is the sole XSS gate for wiki-sourced text.
*/
export function escapeHtml(value: string): string {
return value.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char] ?? char);
}

/**
* Map each distinct node type to a palette color by its position in the list, so
* the graph and legend agree on colors and they stay stable across reloads.
*/
export function colorsForTypes(
types: readonly string[],
palette: readonly string[] = PALETTE,
): Record<string, string> {
const colors: Record<string, string> = {};
types.forEach((type, i) => {
colors[type] = palette[i % palette.length];
});
return colors;
}

/**
* Convert a `#RRGGBB` hex color plus an alpha into an `rgba(...)` string, for
* canvas glow fills and dimming. A non-6-digit input is returned unchanged.
*/
export function hexA(hex: string, alpha: number): string {
const c = (hex || "").replace("#", "");
if (c.length !== 6) return hex;
const channel = (i: number): number => parseInt(c.slice(i, i + 2), 16);
return `rgba(${channel(0)}, ${channel(2)}, ${channel(4)}, ${alpha})`;
}

/**
* Node circle radius in graph units, scaled by page length and capped, with a
* bonus for the entry (anchor) page so it reads as the starting point.
*/
export function nodeRadius(size: number, isAnchor: boolean): number {
return 4 + Math.min(7, (size || 0) / 480) + (isAnchor ? 4 : 0);
}

/**
* Whether a node survives the active search text and type filter. An empty query
* or empty type matches everything.
*/
export function matchesFilter(
node: FilterableNode,
query: string,
type: string,
): boolean {
const haystack = `${node.title} ${node.id} ${(node.tags ?? []).join(" ")}`;
const matchesQuery = !query || haystack.toLowerCase().includes(query);
const matchesType = !type || node.type === type;
return matchesQuery && matchesType;
}

/**
* A stable fingerprint of the graph's topology (its node ids and directed edges).
* When it is unchanged across a reload, the scene can be left untouched so the
* layout and viewport do not snap.
*/
export function signature(graph: Pick<WikiGraph, "nodes" | "edges">): string {
const nodes = graph.nodes
.map((node) => node.id)
.sort()
.join("|");
const edges = graph.edges
.map((edge) => `${edge.source}>${edge.target}`)
.sort()
.join("|");
return `${nodes}::${edges}`;
}

/**
* Strip a leading YAML frontmatter block from a markdown body before it is
* rendered in the reader. A body without frontmatter is returned unchanged.
*/
export function stripFrontmatter(body: string): string {
if (!body.startsWith("---")) return body;
const end = body.indexOf("\n---", 3);
return end === -1 ? body : body.slice(body.indexOf("\n", end + 1) + 1);
}

/**
* Resolve a relative link (`rel`) against a page's directory (`baseDir`) into a
* normalized wiki path, collapsing `.` and `..` segments. Used to turn in-page
* markdown links into node ids for in-app navigation.
*/
export function normalize(baseDir: string, rel: string): string {
const parts = (baseDir ? baseDir.split("/") : []).concat(rel.split("/"));
const out: string[] = [];
for (const part of parts) {
if (part === "" || part === ".") continue;
if (part === "..") out.pop();
else out.push(part);
}
return out.join("/");
}
Loading