neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
파일 기반 라우팅, PulsePoint 반응성, RPC, 명명된 소켓, 프로덕션 준비 도구로 풀스택 Python 애플리케이션을 구축
Build full-stack Python applications with file-based routing, PulsePoint reactivity, RPC, named sockets, and production-ready tooling.
개발 도구GitHub @TheSteelNinjaCode온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Caspian — The Native Python Web Framework for the Reactive Web
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Caspian — The Native Python Web Framework for the Reactive Web
Caspian is a FastAPI-powered full-stack framework that brings reactive UI to Python without a JavaScript backend. You write file-system routes, plain HTML templates, and async def Python — Caspian wires the browser to your server.README 전문 보기 ↗Caspian is a FastAPI-powered full-stack framework that brings reactive UI to Python without a JavaScript backend. You write file-system routes, plain HTML templates, and async def Python — Caspian wires the browser to your server.
pp.rpc(); no controllers, no fetch boilerplate@component functions rendered as HTML-first x-* tagsThe full manual ships inside every project at
node_modules/caspian-utils/dist/docs/(start atindex.md). This README is the tour; that folder is the reference.
Requires Python 3.14+ and Node.js with npm/npx (used for the CLI, Prisma, Tailwind, and the dev stack).
npx create-caspian-app@latest
The wizard asks for a project name, feature toggles (backend-only, Tailwind, Prisma, MCP, TypeScript), and a starter kit (basic, fullstack, api, realtime, or a custom Git source). Then:
npm run dev
npm run dev runs a BrowserSync proxy plus asset watchers, not a Vite dev server that owns the page. The proxy does not always land on its default port — check settings/bs-config.json for the active URL.
A route is a folder with one file: index.py. Markup lives inline in html(r"""..."""), next to the server logic.
# src/app/todos/index.py
from casp.component_decorator import html
from casp.layout import Metadata
from casp.rpc import rpc
from casp.validate import Rule, Validate
from src.lib.prisma import prisma
metadata = Metadata(title="Todos", description="A tiny Caspian todo list.")
async def page():
return html(r"""
<section>
<form onsubmit="{addTodo(event)}">
<input name="title" required />
<button type="submit" disabled="{isSaving}">Add</button>
</form>
<ul>
<template pp-for="(todo, index) in todos">
<li key="{todo.id}" class="border-b p-2">
{index + 1}. {todo.title}
<button onclick="{removeTodo(todo.id)}">Remove</button>
</li>
</template>
</ul>
<p hidden="{todos.length > 0}">Nothing here yet.</p>
<script>
const [todos, setTodos] = pp.state([]);
const [isSaving, setIsSaving] = pp.state(false);
pp.effect(() => {
pp.rpc("list_todos").then(setTodos);
}, []);
async function addTodo(event) {
event.preventDefault();
setIsSaving(true);
try {
const data = Object.fromEntries(
new FormData(event.currentTarget).entries(),
);
setTodos([await pp.rpc("create_todo", data), ...todos]);
event.currentTarget.reset();
} finally {
setIsSaving(false);
}
}
async function removeTodo(id) {
await pp.rpc("delete_todo", { id });
setTodos(todos.filter((todo) => todo.id !== id));
}
</script>
</section>
""")
@rpc()
async def list_todos():
todos = await prisma.todo.find_many(order_by={"id": "desc"})
return [todo.to_dict() for todo in todos]
@rpc()
async def create_todo(title: str):
checked = Validate.with_rules(title, [Rule.REQUIRED, Rule.min(3)])
if checked is not True:
raise ValueError("Title must be at least 3 characters.")
todo = await prisma.todo.create(data={"title": title.strip(), "completed": False})
return todo.to_dict()
@rpc(require_auth=True)
async def delete_todo(id: int):
await prisma.todo.delete(where={"id": int(id)})
return {"deleted": True}
That is the whole loop — no API routes, no client, no serializer. pp.rpc("create_todo", data) posts to the current route; Caspian resolves the decorated function, filters the payload against its signature, runs it, and returns JSON.
The directory structure under src/app is the URL structure.
src/app/index.py -> /
src/app/blog/posts/index.py -> /blog/posts
src/app/users/[id]/index.py -> /users/123 (dynamic segment)
src/app/docs/[...slug]/index.py -> /docs/a/b/c (catch-all)
src/app/(auth)/login/index.py -> /login (route group, no URL segment)
src/app/dashboard/layout.py -> wraps every /dashboard/* page
Path params arrive as one positional dict (async def page(params: dict)); query params inject by name; request injects when declared. page() returns html(r"""...""", **context), or a (page_html, layout_props) tuple whose keys become {{ layout.* }} in a parent layout.
Special files. Only index.py is required — the rest are optional, and each owns a behavior you should not hand-build.
| File | Export | Owns |
|---|---|---|
index.py | page() | The page, plus metadata, route-owned @rpc(), redirects, first-render data |
layout.py | layout() | A subtree shell containing <slot />, plus optional props and metadata |
loading.py | loading() | Loading UI shown while navigating between routes |
not_found.py | page() | Global 404 page |
error.py | page(error_message, error_trace) | Global 500 page |
loading.py is worth knowing because it is easy to reinvent: loading() is synchronous and takes no parameters, its URL scope comes from its folder (the closest ancestor wins), and its markup is injected as raw HTML — Jinja {{ }} interpolates, but <x-*> tags, { } bindings, and <script> do nothing inside it. Mark the pane it replaces with pp-loading-content="true" in the layout. Most subtrees have none, and a route with no loader simply fades; add one only when a section wants it, and never replace it with a spinner component or a navigation-event listener.
PulsePoint borrows React's hook API and its component decomposition model. It does not borrow JSX. This is the single most common source of broken pages.
<!-- ❌ Silently corrupts the page -->
<div class="{cls}">…</div>
{isOpen &&
<div>Panel</div>
} {items.map(item => (
<li>{item.name}</li>
))}
<button className="btn" onClick="{save}">Save</button>
<!-- ✅ The PulsePoint equivalents -->
<div class="{cls}">…</div>
<div hidden="{!isOpen}">Panel</div>
<template pp-for="item in items"><li key="{item.id}">{item.name}</li></template>
<button class="btn" onclick="{save()}">Save</button>
An unquoted class={...} is invalid HTML: the parser splits it on spaces into junk attributes, the component root never compiles, and the route serves a blank page with no console error.
Sanity check: delete every {} from the template. What remains must still be valid HTML.
The directive list is closed. There is no pp-if, pp-show, pp-else, pp-model, or pp-key.
| Syntax | Where | Purpose |
|---|---|---|
{expression} | Text nodes and quoted attribute values | Interpolation |
onclick, oninput, onsubmit, any on* | Any element | Event binding |
pp-for="item in items" / "(item, index) in items" | <template> only | List rendering |
key="{expr}" | The repeated element | Diffing identity |
pp-ref="name" / pp-ref="{expr}" | Native elements and x-* tags | Imperative element access |
defaultvalue / defaultchecked | Form controls | Uncontrolled seed (lowercase) |
pp-style="{cssText}" | Any element | Dynamic inline style (string) |
pp-spread="{...obj}" | Any element | Spread object into attributes |
<token.provider value="{v}"> (lowercase) | Anywhere | Context provider |
pp-spa="false" | An <a> | Opt one link out of SPA navigation |
pp-reset-scroll, pp-scroll-key | A scroll container | Scroll restoration control |
pp-loading-content="true" | The pane swapped during navigation | Where loading.py markup lands |
A form control is controlled (value="{state}" + oninput) or uncontrolled (defaultvalue="{expr}") for its lifetime, never both. Never hand-write runtime-managed attributes (pp-component, pp-owner, pp-ref-forward, pp-loading-url, data-pp-*, …) — the pipeline injects them.
Root shape. Default to one top-level element with the owned <script> inside it. Beyond that: a component with sibling top-level nodes becomes a fragment (the <>…</> equivalent) that adds no element to the DOM — but a fragment has no root, so it cannot receive props; give it a single native root when it takes any. A page or layout with sibling top-level nodes gets a layout-neutral display: contents boundary host instead, so skip the meaningless wrapper <div> when the sections really are siblings.
Component <script> blocks are plain JavaScript, evaluated in component scope. Only top-level declarations reach the template. Props are read from pp.props — there is no injected props variable.
pp.state, pp.reducer, pp.memo, pp.callback, pp.deferredValue, pp.optimisticpp.effect, pp.layoutEffect (cleanups must be synchronous; always pass a dependency array)pp.ref, pp.id (for id/for/aria-* — never index-derived ids), pp.portal, pp.imperativeHandlepp.createContext + a lowercase <themecontext.provider value="{theme}"> tag + pp.context(token) in descendantspp.errorBoundary ([error, reset]), pp.syncExternalStore (subscribe must be pp.callback(..., [])-stable), pp.transitionUtilities: pp.mount, pp.redirect, pp.rpc, pp.socket, and pp.enablePerf / disablePerf / getPerfStats / resetPerfStats.
React APIs with no equivalent: forwardRef, memo() as a wrapper, lazy, Suspense, useInsertionEffect, useActionState, useFormStatus, free-function startTransition. pp.transition() reports an accurate isPending but does not time-slice — rendering is synchronous.
Inside an on* attribute the runtime injects event plus the aliases e, $event, target, currentTarget, and el.
Performance ownership: pp.state means "a render is required". Timers, request generations, cursors, and RPC-only query text belong in pp.ref, whose mutation never renders. Debouncing a setter limits frequency, not render cost.
A component is a @component function whose markup is authored inline and rendered as a kebab-cased x-* tag.
from casp.component_decorator import component, html
from casp.html_attrs import get_attributes, merge_classes
@component
def UserCard(user=None, **props):
attributes = get_attributes({
"class": merge_classes("card", props.pop("class", "")),
"user-name": user["name"],
}, props)
return html(r"""
<div {{ attributes }}>
<h3>{{ user.name }}</h3>
<button onclick="{setLikes(likes + 1)}">Likes: {likes}</button>
<script>
const [likes, setLikes] = pp.state({{ user.likes | json }});
</script>
</div>
""", attributes=attributes, user=user)
html(r"""...""") is the one markup form — always a raw triple-quoted literal. A non-raw string rewrites backslashes, so a regex or \n in the component script means one thing in the source and another at render. Never build markup as an f-string: it inverts the brace dialects ({x} becomes server interpolation), skips autoescaping while still marking the output trusted, and skips the <x-*> scope stash.
Three brace dialects coexist inside that literal: {{ value }} is server-side Jinja (autoescaped), {{ value | json }} safely serializes a server value into a <script>, {# … #} is a Jinja comment, and { value } is left untouched for PulsePoint in the browser.
Composition is Python-import-driven — the import is the registration:
from src.components.Container import Container
from src.components.ui.Button import Button
from src.lib.ppicons import ArrowRight, Search # -> <x-arrow-right />, <x-search />
Container → <x-container />, CommandDialog → <x-command-dialog />. If one file exports several components, import them from that file. Slot content resolves in the scope where it was authored, so the module writing an x-* tag must import it.
Every prop the template reads must be re-emitted on the root. This is the most common silent failure:
x-* tag reach Python as raw string kwargs, kebab-case converted to camelCase — open="{permOpen}" arrives as the literal string "{permOpen}".get_attributes({...}, props) + {{ attributes }}.pp.props from the rendered root's attributes, evaluating brace expressions in the parent's scope.A prop accepted in Python but not re-emitted is silently undefined — no server error, no console warning. Forwarding does not preserve types either: a brace expression keeps its real type, a literal (volume="0") arrives as a string, a valueless attribute arrives as true, None/False/"" are omitted entirely, and JS reserved words such as class are dropped from pp.props.
When tailwindcss: true, merge_classes(...) emits a frontend-ready {twMerge(...)} expression that the browser's twMerge(...) resolves. Pass it straight through — never wrap or re-merge it.
pp.rpc() and @rpc()await pp.rpc(name, data?, optionsOrAbort?)
Posts to the current route and resolves the @rpc() function of that name in its index.py. Serialization switches to FormData automatically when a File is present, the CSRF token is injected as X-CSRF-Token, and server redirects are honored through pp.redirect(). Passing true as the third argument means { abortPrevious: true }.
Options: abortPrevious, url, csrfUrl, credentials, onStream, onStreamError, onStreamComplete, onUploadProgress, onUploadComplete.
Payload safety: RPC keys are filtered against the function signature, so a parameter is client-settable only when declared. Declaring **kwargs opts into the whole payload — do that deliberately, and always derive identity and ownership server-side from the session.
Uploads — pass the File through and read progress:
await pp.rpc(
"upload_asset",
{ file },
{
onUploadProgress: ({ percent }) => setPercent(percent),
onUploadComplete: () => setPercent(100),
},
);
onUploadProgress receives { loaded, total, percent }; total and percent are null when the length is not computable.
Streaming (the path for AI/LLM token output) — a generator @rpc() becomes a text/event-stream response:
@rpc()
async def ask_question(topic: str):
async for chunk in llm.stream(topic):
yield chunk
pp.rpc(
"ask_question",
{ topic },
{
onStream: (chunk) => setAnswer((current) => current + chunk),
onStreamComplete: () => setIsStreaming(false),
},
);
Do not reinvent one-way streaming with raw fetch/ReadableStream, EventSource, or a WebSocket.
from casp.validate import Rule, Validate
email = Validate.email(" [email protected] ") # -> "[email protected]"
count = Validate.int("42") # -> 42
bad = Validate.url("not-a-url") # -> None
checked = Validate.with_rules(password, [Rule.REQUIRED, Rule.min(8)])
if checked is not True:
return {"error": checked}
Validate covers strings and identifiers (string, email, url, ip, uuid, ulid, cuid, cuid2, nanoid), numbers (int, big_int, float, decimal), dates (date, date_time), boolean, and structured values (json, enum, enum_class). Validate.string() trims and HTML-escapes by default.
Browser-side checks are UX only. Server-side validation at the RPC/route boundary is authoritative.
Session-based, configured centrally in src/lib/auth/auth_config.py and wired in main.py:
from casp.auth import Auth, GithubProvider, GoogleProvider, configure_auth
from src.lib.auth.auth_config import build_auth_settings
configure_auth(build_auth_settings())
Auth.set_providers(GithubProvider(), GoogleProvider())
The auth instance exposes sign_in(data, token_validity=None, redirect_to=False), sign_out(redirect_to=None), is_authenticated(), get_payload(), refresh_session(), and check_role(user, allowed_roles). Guards are @rpc(require_auth=True) for actions and @require_auth() / @guest_only() for pages, with public-vs-private route policy declared centrally.
OAuth is already wired. Auth.set_providers(...) registers /api/auth/signin/{google,github} and /api/auth/callback/{google,github}. Link a button and set the credentials in .env — do not hand-roll the flow.
Redirect ownership is centralized. Do not re-implement next= handling or post-login routing in a sign-in page; auth_config.py owns protected-route redirects, auth-route redirects, and default_signin_redirect.
Enabled by "prisma": true. Define one prisma/schema.prisma; the typed Python client is generated into src/lib/prisma/.
from src.lib.prisma import prisma
users = await prisma.user.find_many(
where={"active": True},
include={"userRole": True},
order_by={"createdAt": "desc"},
)
Two generators, one schema. After any schema change, run exactly two commands in order — sync the database, then regenerate the Python ORM:
npx prisma migrate dev
npx ppy generate
npx prisma generate builds the Node client used by prisma/seed.ts and writes zero Python — it is never a substitute for npx ppy generate. Run it before npx prisma db seed, and note that db seed may clear or overwrite tables; confirm the datasource first.
src/lib/prisma/** and settings/prisma-schema.json are generated. Never hand-edit them, and never add a second data layer alongside the ORM.
Every optional capability is gated by one flag in caspian.config.json. That file is the single source of truth — a doc or example mentioning a feature does not mean it is enabled in your project. To turn one on after scaffold, set the flag and run npx casp update project.
| Flag | Enables |
|---|---|
backendOnly | API/service mode with no frontend assets |
tailwindcss | Tailwind v4 + PostCSS pipeline, merge_classes / twMerge contract |
typescript | TypeScript frontend tooling and the Vite build path |
prisma | Prisma schema, migrations, and the generated Python ORM |
mcp | A FastMCP server mounted into the same app (/mcp) |
websocket | Named sockets — @socket() in Python, pp.socket() in the browser |
Use RPC for ordinary reads, writes, uploads, and one-way streams. Reach for a socket only when both sides may speak at any time: chat, collaboration, presence, multiplayer state. A named socket is the socket counterpart of @rpc()/pp.rpc() — one decorated Python function, one browser call.
from src.lib.websocket.sockets import Socket, socket
@socket()
async def echo(label: str, socket: Socket):
while (text := await socket.recv()) is not None:
if not await socket.send(f"{label}: {text}"):
break # The browser is gone.
const sock = pp.ref(null);
pp.effect(() => {
sock.current = pp.socket(
"echo",
{ label: "you" },
{
onMessage: (value) => append(value),
onError: (error) => setStatus(error.message),
},
);
return () => sock.current.close();
}, []);
Open the socket inside pp.effect(..., []), keep the handle in pp.ref(...), close it in the cleanup. The handle exposes send(value), close(code?, reason?), and readyState; handlers are onOpen, onMessage(value), onError(error), onClose({ code, reason, wasClean }).
The wire, in short:
main.py. The function is named in a query parameter, so socket names are application-wide and a duplicate is refused at registration.pp.rpc would have posted — not in the URL, which every proxy logs. Keys are filtered against the handler signature, like RPC.{"error": "..."}, then a close — routed to onError, never onMessage. A handler that returns ends the conversation, and await socket.send(...) returning False means the browser is gone: a signal to stop, not an error to report.socket.sender() returns a detached SocketSender safe to hold in shared state; a room is a SocketPool of senders that prunes departed connections as it broadcasts. Keep authenticated and guest traffic in separate pools.@socket() is public, @socket(require_auth=True) needs a session, @socket(allowed_roles=[...]) adds RBAC.A socket in a route's index.py registers when that route first renders; one shared by several routes belongs in src/lib/**. A hand-written @app.websocket(...) route stays the escape hatch for wires the JSON-frame contract cannot carry (binary frames, non-JSON protocols) — and then the app owns every security check itself.
When mcp: true, a FastMCP server is mounted into the same app so one deploy serves both web and MCP. The endpoint sits outside the routing tree, so AuthMiddleware does not cover it — MCP_AUTH_TOKEN is its credential. With no token it stays open in development and returns 503 in production.
Caspian ships fail-closed. Worth knowing before changing any of it:
APP_ENV resolves fail-closed. Only an explicit development value (dev, development, local, staging, test, testing) enables relaxations. Unset or misspelled counts as production.{ / } as entities on every non-Markup value, so stored user data cannot execute as a template expression. Markup is the trust boundary — | safe, get_attributes(...), merge_classes(...), and the json filter legitimately keep their braces.Cache(...) cannot override it./uploads serves user content in attachment mode; only real image types render inline. First-party /css, /js, and /assets stay inline.scope["type"] == "websocket", so AuthMiddleware never sees a handshake — HTTP route privacy does not extend to a socket.Security-relevant environment variables: MCP_AUTH_TOKEN, RATE_LIMIT_PAGES (default 200/minute), CONTENT_SECURITY_POLICY (replaces the default policy wholesale), MAX_WEBSOCKET_CONNECTIONS, MAX_WEBSOCKET_MESSAGE_BYTES, MAX_WEBSOCKET_MESSAGES_PER_WINDOW, WEBSOCKET_RATE_WINDOW_SECONDS, WEBSOCKET_IDLE_TIMEOUT_SECONDS, and WEBSOCKET_ALLOWED_ORIGINS (required in production — the same-origin fallback is derived from the client-supplied Host header and is development-only).
my-app/
├── main.py # FastAPI entry point, middleware stack, socket + MCP mounts
├── caspian.config.json # Feature flags — the single source of truth
├── prisma/schema.prisma
├── src/
│ ├── app/ # File-system routes
│ │ ├── layout.py # Root layout
│ │ ├── index.py # Home page (markup + logic in one file)
│ │ ├── globals.css
│ │ ├── not_found.py # Global 404
│ │ ├── error.py # Global 500
│ │ ├── dashboard/loading.py # Optional: /dashboard navigation loading UI
│ │ └── users/[id]/index.py # /users/:id
│ ├── components/ # Reusable UI (@component)
│ └── lib/ # Non-UI code
│ ├── auth/auth_config.py
│ ├── prisma/ # Generated Python ORM — do not edit
│ ├── websocket/ # Named sockets: @socket(), Socket, SocketPool
│ └── mcp/ # FastMCP server (when mcp: true)
├── public/ # Static assets, incl. the PulsePoint runtime and uploads
└── settings/ # Dev stack config and generated indexes
Placement rules
@rpc() actions, redirects, validation) stays in that route's index.py. Move it to src/lib/** only when genuinely shared.src/components/; helpers, services, adapters go in src/lib/.x-* chunks (topbar, sidebar, sections, forms, footer), not a wall of markup. Plan the breakdown before writing the route.src/lib/prisma/**, settings/prisma-schema.json, settings/files-list.json, settings/component-map.json, public/css/styles.css.npx create-caspian-app my-app
| Flag | Description |
|---|---|
-y | Non-interactive; skip all prompts |
--backend-only | API/service project, no frontend assets |
--tailwindcss | Enable Tailwind CSS |
--typescript | Enable TypeScript frontend tooling |
--prisma | Enable Prisma ORM |
--mcp | Enable MCP server scaffolding |
--starter-kit=<kit> | basic, fullstack, api, realtime, custom |
--starter-kit-source=<url> | Git repository for --starter-kit=custom |
--list-starter-kits | Print the built-in starter catalog |
In -y mode every feature defaults to false, and starter-kit presets can still be overridden by explicit flags. websocket has no create flag — enable it in caspian.config.json after scaffold, then run npx casp update project (which also accepts --tag beta / --version 1.2.3 and -y). Use excludeFiles in caspian.config.json to protect files you have customized (commonly ./src/lib/auth/auth_config.py) from being overwritten on update.
Project scripts
| Command | What it does |
|---|---|
npm run dev | Full local stack: BrowserSync proxy, Tailwind watch, asset watch |
npm run build | Build Tailwind and regenerate the route/component index |
npm run static | Export every static route to static/ (SSG) |
npm run static:serve | Preview the exported folder on an auto-selected free loopback port |
These are opt-in workflows — don't run them as a validation step just because source files changed.
npm run static boots the app and writes static/<route>/index.html plus copied public assets — the equivalent of Next.js output: export, running npm run build first so the export walks a fresh route index. Policy is warn & skip: dynamic routes are pre-rendered only when their index.py exports static_paths (the getStaticPaths equivalent); auth-gated, non-200, and non-HTML routes are reported and skipped.
npm run static:serve serves only static/, binds loopback 127.0.0.1 (network exposure is opt-in via HOST=0.0.0.0), and walks upward from port 8000 until it finds a free one — read the port it prints. pp.rpc(), auth, WebSockets, streaming, and per-request server data are all inert in a static export.
Two CLIs install ready-made Python components into src/lib/, where they behave like any other x-* tag:
npx ppicons add Rocket # 1,500+ Lucide-based icons
npx maddex add button card dialog # shadcn-style UI kit
from src.lib.ppicons import Rocket # -> <x-rocket class="size-6" />
from src.lib.maddex.Button import Button # -> <x-button variant="outline">Continue</x-button>
The full documentation ships inside every Caspian project at node_modules/caspian-utils/dist/docs/ — start with index.md, which routes you to the right feature guide.
MIT