diff --git a/README.md b/README.md
index 49af85f..e96f6c0 100755
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
# B3Note
-
+
+
+
+
**Private notes on the Internet Computer.** Notes are encrypted in your browser, so the canister only ever
stores ciphertext. With Internet Identity the key is derived from your identity by the IC's
@@ -19,10 +22,15 @@ that **burns after reading**.
can request the share's key, and it does so once per view. Readers do not need to sign in.
- **Sign in your way.** Use Internet Identity, or a *guest key*: a private key kept in your browser, with a
recovery key you can save and import on another device.
-- **Optional AI assistant** on the IC's [LLM canister](https://github.com/dfinity/llm), **off by
- default**: summarize, suggest a title or tags, improve the writing, translate, and ask questions
- across your notes. Text sent to it leaves the end-to-end encryption, so a controller has to switch it on
- (`ai_enabled`); until then the app shows no AI features. When on, nothing is sent until you choose an
+- **On-device AI assistant**: summarize, suggest a title or tags, improve the writing, fix grammar,
+ shorten, pull out action items, translate, continue writing, and ask questions across your notes, with
+ a model that runs in your browser, so nothing leaves the end-to-end encryption. It uses Chrome's
+ built-in Gemini Nano when the browser has it, and otherwise downloads an open model
+ (Qwen2.5-1.5B-Instruct, about 1 GB, once, after you ask) and runs it on the GPU with
+ [WebLLM](https://github.com/mlc-ai/web-llm). Browsers with neither show no on-device AI.
+- **Optional AI on the Internet Computer** through the IC's [LLM canister](https://github.com/dfinity/llm),
+ **off by default**. Text sent to it leaves the end-to-end encryption, so a controller has to switch it
+ on (`ai_enabled`); when on, it is a second choice in the assistant, nothing is sent until you choose an
action, and the app asks for consent first.
- **Your data stays yours.** Export to JSON or Markdown, import Markdown files, and delete your account.
@@ -60,7 +68,17 @@ that **burns after reading**.
signs `(domain, share id, transport key)`. The canister checks the signature, spends a view, and has the
share key derived and encrypted to the reader. A transport key can be used once per share, so a
replayed request cannot burn another view.
-- **AI (when switched on).** The assistant needs plaintext. Only the text you pick for an action (or,
+- **On-device AI.** The model reads the note in the browser that decrypted it. The only network traffic
+ is the model's files on first use (Hugging Face and the MLC model library on GitHub), which is why the
+ page's CSP allows those hosts and `'wasm-unsafe-eval'` for the model's WebAssembly. Both are pinned to a
+ commit, and WebLLM checks the model's config, its tokenizer and its WebAssembly library against hashes
+ before it runs them (`frontend/src/lib/localAi.ts`), so a push to either repository cannot change the
+ code that reads your notes.
+- **No remote images.** Fetching an image sends its URL to its host, so a note's Markdown or an AI answer
+ (which a prompt hidden in a note could steer) could carry text out that way, and an image in a shared
+ note would tell its sender when it was opened. Markdown shows images as links instead, and the CSP's
+ `img-src` allows only the app itself.
+- **AI on the canister (when switched on).** The assistant needs plaintext. Only the text you pick for an action (or,
for *Ask your notes*, the few notes your browser ranks as relevant) goes to the LLM canister.
Everything else stays encrypted.
diff --git a/frontend/index.html b/frontend/index.html
index 8aab8fa..38642be 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -6,15 +6,16 @@
B3Note: private notes on the Internet Computer
-
+
+
-
+
diff --git a/frontend/package.json b/frontend/package.json
index 8df1704..de79a61 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,11 +17,14 @@
},
"dependencies": {
"@candid-core/schema": "0.3.0",
+ "@fontsource-variable/schibsted-grotesk": "5.3.0",
+ "@fontsource/martian-mono": "5.3.0",
"@ic-reactor/core": "4.0.0-beta.3",
"@ic-reactor/react": "4.0.0-beta.3",
"@icp-sdk/auth": "^10.0.1",
"@icp-sdk/core": "^6.1.0",
"@icp-sdk/vetkeys": "^0.7.0",
+ "@mlc-ai/web-llm": "0.2.85",
"@tanstack/react-query": "^5.104.1",
"@tanstack/react-router": "^1.170.41",
"idb-keyval": "^6.2.2",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 839c4d9..bb7aa8d 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -11,6 +11,12 @@ importers:
'@candid-core/schema':
specifier: 0.3.0
version: 0.3.0
+ '@fontsource-variable/schibsted-grotesk':
+ specifier: 5.3.0
+ version: 5.3.0
+ '@fontsource/martian-mono':
+ specifier: 5.3.0
+ version: 5.3.0
'@ic-reactor/core':
specifier: 4.0.0-beta.3
version: 4.0.0-beta.3(@candid-core/schema@0.3.0)(@icp-sdk/core@6.1.0)(@noble/curves@1.9.7)(@tanstack/query-core@5.104.1)
@@ -26,6 +32,9 @@ importers:
'@icp-sdk/vetkeys':
specifier: ^0.7.0
version: 0.7.0(@icp-sdk/core@6.1.0)
+ '@mlc-ai/web-llm':
+ specifier: 0.2.85
+ version: 0.2.85
'@tanstack/react-query':
specifier: ^5.104.1
version: 5.104.1(react@19.3.0)
@@ -210,6 +219,12 @@ packages:
'@noble/hashes':
optional: true
+ '@fontsource-variable/schibsted-grotesk@5.3.0':
+ resolution: {integrity: sha512-ndS6H/KICWANchlHF3q4UdZD+xrZ3Ji0rUobIuXlgvDUzDfqZDsVuXPXZb636nJZMHE9zZ9Fmwgqazm8hMB50Q==}
+
+ '@fontsource/martian-mono@5.3.0':
+ resolution: {integrity: sha512-0iTnU6SZi6j5z4ymwlZGep6akvp/xWm+r+WyZO9hMfDJWX+dsFLipQp9zO02S0lkUQ+zAahcZNy3XLiNP+gVqQ==}
+
'@ic-reactor/core@4.0.0-beta.3':
resolution: {integrity: sha512-8UwwxiAHIlu80LC3FLyBdEHhe24uIuUiMofAzU9NvB1cVbM0F/RE9SpGKPyMqwwIobWIg/+cPSWe+WG+5hXv+Q==}
engines: {node: '>=18'}
@@ -270,6 +285,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mlc-ai/web-llm@0.2.85':
+ resolution: {integrity: sha512-CwUiSAvDCYnmAZlpPMkurPHDfBLq6fvdRB3vRgoA894hRG/V/BSeY9bmMm6iIIU5VpjJxfb/dLPpJX2at0L9rw==}
+
'@noble/curves@1.9.7':
resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
engines: {node: ^14.21.3 || >=16}
@@ -981,6 +999,10 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
+ loglevel@1.9.2:
+ resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
+ engines: {node: '>= 0.6.0'}
+
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -1581,6 +1603,10 @@ snapshots:
optionalDependencies:
'@noble/hashes': 2.4.0
+ '@fontsource-variable/schibsted-grotesk@5.3.0': {}
+
+ '@fontsource/martian-mono@5.3.0': {}
+
'@ic-reactor/core@4.0.0-beta.3(@candid-core/schema@0.3.0)(@icp-sdk/core@6.1.0)(@noble/curves@1.9.7)(@tanstack/query-core@5.104.1)':
dependencies:
'@candid-core/schema': 0.3.0
@@ -1645,6 +1671,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
+ '@mlc-ai/web-llm@0.2.85':
+ dependencies:
+ loglevel: 1.9.2
+
'@noble/curves@1.9.7':
dependencies:
'@noble/hashes': 1.8.0
@@ -2219,6 +2249,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
+ loglevel@1.9.2: {}
+
longest-streak@3.1.0: {}
lru-cache@11.5.3: {}
diff --git a/frontend/public/.ic-assets.json5 b/frontend/public/.ic-assets.json5
index 32d722b..03a323b 100644
--- a/frontend/public/.ic-assets.json5
+++ b/frontend/public/.ic-assets.json5
@@ -1,9 +1,17 @@
// Headers for the asset canister (icp-cli asset-canister recipe).
+//
+// On-device AI: WebLLM compiles the model's WebAssembly ('wasm-unsafe-eval', not 'unsafe-eval') and
+// downloads the model's files once from Hugging Face and the MLC model library on GitHub, both pinned
+// and hash-checked (src/lib/localAi.ts). Only those files come in; no note text goes out. Chrome's
+// built-in model needs none of this.
+//
+// img-src allows no other host: the app has no remote images, and Markdown shows a note's or an AI
+// answer's images as links (src/components/Markdown.tsx), since loading one would send its URL out.
[
{
match: "**/*",
headers: {
- "Content-Security-Policy": "default-src 'self'; script-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* https://icp0.io https://*.icp0.io https://icp-api.io https://ic0.app https://*.ic0.app; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' http://localhost:* http://127.0.0.1:* https://icp0.io https://*.icp0.io https://icp-api.io https://ic0.app https://*.ic0.app https://huggingface.co https://*.huggingface.co https://*.hf.co https://raw.githubusercontent.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png
index 51fc2b0..d46fc3c 100644
Binary files a/frontend/public/android-chrome-192x192.png and b/frontend/public/android-chrome-192x192.png differ
diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png
index a177ad0..867d210 100644
Binary files a/frontend/public/android-chrome-512x512.png and b/frontend/public/android-chrome-512x512.png differ
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png
index c4e2a64..52e81e0 100644
Binary files a/frontend/public/apple-touch-icon.png and b/frontend/public/apple-touch-icon.png differ
diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png
index 88d16d2..8de6d05 100644
Binary files a/frontend/public/favicon-16x16.png and b/frontend/public/favicon-16x16.png differ
diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png
index 6014473..c7fa952 100644
Binary files a/frontend/public/favicon-32x32.png and b/frontend/public/favicon-32x32.png differ
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
index 849b187..a216a52 100644
Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/logo-long-dark.svg b/frontend/public/logo-long-dark.svg
new file mode 100644
index 0000000..3a97e2c
--- /dev/null
+++ b/frontend/public/logo-long-dark.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/public/logo-long.svg b/frontend/public/logo-long.svg
index f780c0a..7addb75 100644
--- a/frontend/public/logo-long.svg
+++ b/frontend/public/logo-long.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg
index 9d1ddd0..d198082 100644
--- a/frontend/public/logo.svg
+++ b/frontend/public/logo.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/frontend/public/maskable-512x512.png b/frontend/public/maskable-512x512.png
index cb0453e..27edfdd 100644
Binary files a/frontend/public/maskable-512x512.png and b/frontend/public/maskable-512x512.png differ
diff --git a/frontend/public/safari-pinned-tab.svg b/frontend/public/safari-pinned-tab.svg
index 7010a9b..9a776bf 100644
--- a/frontend/public/safari-pinned-tab.svg
+++ b/frontend/public/safari-pinned-tab.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest
index 33e05f5..49e178c 100644
--- a/frontend/public/site.webmanifest
+++ b/frontend/public/site.webmanifest
@@ -1,7 +1,7 @@
{
"name": "B3Note",
"short_name": "B3Note",
- "description": "End-to-end encrypted notes on the Internet Computer, with burn-after-reading links.",
+ "description": "End-to-end encrypted notes on the Internet Computer, with burn-after-reading links and on-device AI.",
"start_url": "/notes",
"icons": [
{ "src": "/logo.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
@@ -9,7 +9,7 @@
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
- "theme_color": "#6d4aff",
- "background_color": "#000000",
+ "theme_color": "#121110",
+ "background_color": "#121110",
"display": "standalone"
}
diff --git a/frontend/scripts/brand-icons.py b/frontend/scripts/brand-icons.py
index b77dd8d..7d2a018 100644
--- a/frontend/scripts/brand-icons.py
+++ b/frontend/scripts/brand-icons.py
@@ -1,106 +1,69 @@
-"""Generates the B3Note icons: the B3 family's mark (B3Forge's geometry, a grey B with a 3 and a
-multicolour dot) with the product's letter, a gradient N, plugged into it.
+"""Generates the B3Note icons: B3Pay's mark (a stem of blocks and a 3) with its node folded into a
+note, as B3Wallet and B3Forge change only what the node does. The mark is a DRAFT until it is signed
+off and added to B3Pay's tools/logo/build.js; the geometry below is copied from the B3Note design system.
python3 frontend/scripts/brand-icons.py
-writes the SVGs below. The PNGs in frontend/public are these rasterized at the sizes their names
-give: icon.svg for apple-touch-icon (180) and android-chrome (192, 512), maskable.svg for
-maskable-512x512, and mark.svg, transparent, for the favicons (16, 32, and 16+32+48 in favicon.ico).
-mark.svg is also logo.svg, mono.svg is safari-pinned-tab.svg.
+writes the SVGs below. The PNGs in frontend/public are these rasterized at the sizes their names give:
+icon.svg for apple-touch-icon (180) and android-chrome (192, 512), maskable.svg for maskable-512x512,
+and favicon.svg for the favicons (16, 32, and 16+32+48 in favicon.ico). favicon.svg is also logo.svg,
+mono.svg is safari-pinned-tab.svg, logo-long.svg and logo-long-dark.svg are the README's lockup.
"""
-import math, sys, os
+import os, sys
OUT = sys.argv[1]
-GREY = "#808080"
-# Top to bottom, sampled from B3Forge's F bar.
-GRADIENT = ["#29abe2", "#348ccc", "#4a4198", "#8e2480", "#ea1f79", "#f04543", "#f3712b", "#f9a037", "#fbb03b"]
-# Clockwise from 3 o'clock, sampled from B3Forge's dot.
-CONIC = ["#802482", "#4a4298", "#3b71b9", "#2ba6de", "#91ae90", "#c3af68", "#f89634", "#f4722b", "#f0552c", "#ef3855", "#ed2471", "#b5217d"]
-
-# B3ForgeIcon.tsx, in its 366 x 500 space.
-B_OUTLINE = "M11 452.549V485.99C11 485.996 11.0045 486 11.01 486H216.751C349.884 482.439 404.347 306.604 289.369 241.31C287.553 240.123 287.553 238.936 289.369 237.749C382.562 165.331 328.541 21.6845 209.932 11H11.01C11.0045 11 11 11.0045 11 11.01V44.4507"
-THREE = "M177.152 75.5514C198.455 75.5515 215.402 83.3523 226.95 95.8664C238.322 108.191 243.794 124.358 243.794 140.195C243.794 156.033 238.322 172.2 226.95 184.524C218.939 193.206 208.328 199.618 195.468 202.732C202.831 212.498 207.09 224.888 207.09 238.756C207.09 252.15 203.117 264.163 196.213 273.768C212.269 276.356 225.371 283.353 235.027 293.323C247.25 305.943 253.249 322.604 253.34 339.028C253.432 355.447 247.622 372.128 235.6 384.783C223.429 397.596 205.606 405.528 183.219 405.529H94.8667C88.1403 405.529 82.6875 400.075 82.6872 393.349C82.6873 386.623 88.1402 381.17 94.8667 381.17H183.219C199.561 381.169 210.785 375.538 217.94 368.007C225.245 360.316 229.042 349.871 228.983 339.163C228.923 328.461 225.012 317.995 217.53 310.271C210.191 302.694 198.708 297.022 182.008 297.022H153.696C152.031 297.149 150.341 297.218 148.629 297.218C119.284 297.218 96.5496 278.162 91.3104 250.936H52.4109C45.6844 250.936 40.2314 245.483 40.2314 238.756C40.2314 232.03 45.6844 226.577 52.4109 226.577H91.3104C96.5496 199.351 119.284 180.295 148.629 180.295C150.296 180.295 151.942 180.359 153.565 180.481H177.152C192.157 180.48 202.441 175.165 209.048 168.005C215.83 160.655 219.435 150.59 219.435 140.195C219.435 129.801 215.83 119.736 209.048 112.386C202.441 105.226 192.157 99.9104 177.152 99.9104H92.4308C85.7042 99.9104 80.2513 94.4574 80.2513 87.7309C80.2513 81.0043 85.7042 75.5514 92.4308 75.5514H177.152ZM148.629 199.782C125.84 199.782 109.655 215.967 109.655 238.756C109.655 261.545 125.84 277.731 148.629 277.731C171.418 277.731 187.603 261.545 187.603 238.756C187.603 215.967 171.418 199.782 148.629 199.782Z"
-DOT = (148.628, 238.756, 33.0)
-# B3Forge's exported icons draw the mark bolder than the component (about 29 units).
-STROKE = 28
-THREE_OUTLINE = 5
-# The N, left of the connector, as tall as B3Forge's F.
-N_TOP, N_BOTTOM = 177.0, 300.5
-N_LEFT, N_RIGHT = -76.0, 4.0
-CONTENT = (N_LEFT - STROKE / 2, -3.0, 369.0, 500.0) # x0, y0, x1, y1 in mark space
-
-
-def lerp(a, b, t):
- pa = [int(a[i:i + 2], 16) for i in (1, 3, 5)]
- pb = [int(b[i:i + 2], 16) for i in (1, 3, 5)]
- return "#%02x%02x%02x" % tuple(round(x + (y - x) * t) for x, y in zip(pa, pb))
-
-
-def conic_slices(cx, cy, r, mono=None):
- parts, n = [], 120
- for i in range(n):
- a0, a1 = 2 * math.pi * i / n, 2 * math.pi * (i + 1.15) / n
- pos = i / n * len(CONIC)
- k = int(pos)
- colour = mono or lerp(CONIC[k % len(CONIC)], CONIC[(k + 1) % len(CONIC)], pos - k)
- x0, y0 = cx + r * math.cos(a0), cy + r * math.sin(a0)
- x1, y1 = cx + r * math.cos(a1), cy + r * math.sin(a1)
- parts.append(f'')
- return "".join(parts)
-
-
-def mark(mono=None):
- grey = mono or GREY
- n_stroke = mono or "url(#b3note-n)"
- n_path = f"M{N_LEFT} {N_BOTTOM}V{N_TOP}L{N_RIGHT} {N_BOTTOM}V{N_TOP}"
- defs = "" if mono else (
- f''
- + "".join(f'' for i, c in enumerate(GRADIENT))
- + "")
- return (defs
- + f''
- + f''
- + f''
- + conic_slices(*DOT, mono=mono))
-
-
-def svg(size, fill, background=None, mono=None, square=True):
- """`fill`: the share of the canvas the mark's height takes."""
- x0, y0, x1, y1 = CONTENT
- w, h = x1 - x0, y1 - y0
- if square:
- scale = size * fill / h
- tx = (size - w * scale) / 2 - x0 * scale
- ty = (size - h * scale) / 2 - y0 * scale
- vb_w = vb_h = size
- else:
- scale, tx, ty = 1, -x0, -y0
- vb_w, vb_h = w, h
- bg = f'' if background else ""
- return (f'")
-
-
-def wordmark():
- """The mark beside "B3Note", for the README."""
- x0, y0, x1, y1 = CONTENT
- scale = 200 / (y1 - y0)
- tx, ty = 20 - x0 * scale, 20 - y0 * scale
- text_x = 20 + (x1 - x0) * scale + 36
- return (f'')
+INK_DARK, INK_LIGHT = "#f1ede6", "#1a1714" # B3Pay ink on dark and on light grounds
+NODE_DARK, NODE_LIGHT = "#f0b35a", "#e8a33d" # B3Pay accent: the node is always amber
+SURFACE = "#121110" # B3Pay's graphite surface
+
+# B3Pay's mark on its 96-unit grid, with the node's box (14.7 units) holding a folded sheet.
+SHEET = "M21.4 38.65H24.35A1 1 0 0 1 25.35 39.65V46.4H32.1A1 1 0 0 1 33.1 47.4V50.35A3 3 0 0 1 30.1 53.35H21.4A3 3 0 0 1 18.4 50.35V41.65A3 3 0 0 1 21.4 38.65Z"
+FOLD = "M27.1 38.65L33.1 44.65H28.1A1 1 0 0 1 27.1 43.65Z"
+THREE = "M65.25 62.5C65.25 59.7152 64.1437 57.0445 62.1745 55.0755C60.2055 53.1063 57.5348 52 54.75 52H39.75C38.0932 52 36.75 50.6568 36.75 49V43C36.75 41.3432 38.0932 40 39.75 40H52.75C55.0042 40 57.1662 39.1045 58.7605 37.5105C60.3545 35.9162 61.25 33.7542 61.25 31.5C61.25 29.2458 60.3545 27.0837 58.7605 25.4895C57.1662 23.8955 55.0042 23 52.75 23H39.75C38.0932 23 36.75 21.6568 36.75 20V14C36.75 12.3431 38.0932 11 39.75 11H52.75C58.187 11 63.4012 13.1598 67.2458 17.0043C71.0902 20.8488 73.25 26.063 73.25 31.5C73.25 36.3135 71.5563 40.9735 68.4652 44.6635C71.1983 46.765 73.4115 49.4663 74.9347 52.5588C76.4578 55.6515 77.25 59.0528 77.25 62.5C77.25 68.4673 74.8795 74.1903 70.66 78.41C66.4403 82.6295 60.7172 85 54.75 85H39.75C38.0932 85 36.75 83.6568 36.75 82V76C36.75 74.3432 38.0932 73 39.75 73H54.75C57.5348 73 60.2055 71.8937 62.1745 69.9245C64.1437 67.9555 65.25 65.2848 65.25 62.5Z"
+# The outlined wordmark "B3Note": Rubik 600, kerned, -0.03em, in B3Pay's lockup frame.
+LOCKUP_VIEWBOX = "218.75 -758.333 4423.717 978.333"
+WORD_X = 1110.278
+WORD = "M99 0Q88 0 81 -7Q74 -14 74 -25V-675Q74 -686 81 -693Q88 -700 99 -700H396Q479 -700 532 -676Q585 -652 611 -608Q636 -564 636 -505Q636 -468 622 -440Q609 -412 589 -394Q570 -376 552 -367Q591 -349 621 -307Q650 -266 650 -209Q650 -146 622 -99Q594 -52 539 -26Q484 0 405 0ZM233 -117H386Q435 -117 460 -145Q485 -172 485 -210Q485 -249 460 -275Q434 -302 386 -302H233ZM233 -417H377Q424 -417 448 -441Q471 -464 471 -501Q471 -538 448 -560Q424 -583 377 -583H233Z M983 10Q909 10 856 -8Q803 -26 769 -54Q735 -82 718 -115Q701 -149 700 -180Q700 -189 706 -195Q712 -201 721 -201H831Q842 -201 849 -196Q856 -192 862 -181Q870 -158 888 -144Q906 -130 931 -124Q955 -119 983 -119Q1039 -119 1071 -145Q1103 -171 1103 -218Q1103 -266 1073 -287Q1043 -308 988 -308H881Q871 -308 864 -315Q857 -322 857 -332V-381Q857 -393 861 -401Q866 -409 871 -413L1043 -571H761Q750 -571 743 -578Q736 -585 736 -595V-675Q736 -686 743 -693Q750 -700 761 -700H1205Q1216 -700 1223 -693Q1230 -686 1230 -675V-603Q1230 -592 1226 -585Q1222 -577 1216 -573L1053 -412L1063 -411Q1122 -405 1168 -382Q1214 -359 1241 -316Q1267 -274 1267 -209Q1267 -141 1230 -92Q1193 -43 1129 -17Q1065 10 983 10Z M1378 0Q1367 0 1360 -7Q1353 -14 1353 -25V-675Q1353 -686 1360 -693Q1367 -700 1378 -700H1469Q1484 -700 1491 -693Q1498 -686 1500 -682L1767 -265V-675Q1767 -686 1774 -693Q1781 -700 1791 -700H1893Q1904 -700 1911 -693Q1918 -686 1918 -675V-25Q1918 -15 1911 -7Q1904 0 1893 0H1801Q1786 0 1779 -7Q1773 -15 1770 -18L1504 -422V-25Q1504 -14 1497 -7Q1490 0 1479 0Z M2263 10Q2180 10 2124 -17Q2068 -45 2039 -96Q2009 -147 2006 -215Q2005 -235 2005 -260Q2005 -286 2006 -305Q2009 -374 2040 -425Q2071 -475 2127 -503Q2183 -530 2263 -530Q2342 -530 2398 -503Q2454 -475 2485 -425Q2516 -374 2519 -305Q2521 -286 2521 -260Q2521 -235 2519 -215Q2516 -147 2486 -96Q2457 -45 2401 -17Q2345 10 2263 10ZM2263 -100Q2312 -100 2337 -131Q2362 -161 2364 -220Q2365 -235 2365 -260Q2365 -285 2364 -300Q2362 -358 2337 -389Q2312 -420 2263 -420Q2214 -420 2189 -389Q2163 -358 2161 -300Q2160 -285 2160 -260Q2160 -235 2161 -220Q2163 -161 2189 -131Q2214 -100 2263 -100Z M2843 0Q2781 0 2737 -21Q2693 -41 2671 -84Q2648 -127 2648 -192V-402H2570Q2559 -402 2552 -409Q2545 -416 2545 -427V-495Q2545 -506 2552 -513Q2559 -520 2570 -520H2648V-685Q2648 -696 2655 -703Q2663 -710 2673 -710H2771Q2782 -710 2789 -703Q2796 -696 2796 -685V-520H2921Q2931 -520 2938 -513Q2945 -506 2945 -495V-427Q2945 -416 2938 -409Q2931 -402 2921 -402H2796V-204Q2796 -166 2809 -145Q2823 -123 2857 -123H2930Q2940 -123 2947 -116Q2954 -109 2954 -99V-25Q2954 -14 2947 -7Q2940 0 2930 0Z M3243 10Q3128 10 3061 -54Q2993 -118 2989 -240Q2989 -248 2989 -262Q2989 -275 2989 -283Q2993 -360 3024 -415Q3055 -471 3111 -500Q3167 -530 3242 -530Q3326 -530 3383 -496Q3439 -461 3467 -402Q3496 -342 3496 -265V-242Q3496 -232 3489 -225Q3481 -218 3471 -218H3144Q3144 -217 3144 -215Q3144 -212 3144 -210Q3145 -178 3156 -151Q3168 -125 3189 -109Q3211 -93 3241 -93Q3266 -93 3282 -100Q3298 -107 3309 -117Q3319 -127 3324 -134Q3333 -144 3339 -147Q3344 -149 3356 -149H3460Q3470 -149 3476 -144Q3483 -138 3482 -128Q3481 -112 3465 -88Q3450 -65 3420 -42Q3390 -20 3345 -5Q3301 10 3243 10ZM3144 -309H3340V-311Q3340 -347 3329 -374Q3317 -400 3295 -415Q3273 -430 3242 -430Q3211 -430 3189 -415Q3167 -400 3156 -374Q3144 -347 3144 -311Z"
+
+
+def mark(ink, node):
+ return (
+ f''
+ f''
+ f''
+ f''
+ )
+
+
+def tile(scale, radius):
+ """The mark at `scale` on a graphite square; radius 0 is full-bleed (the platform rounds it)."""
+ offset = 48 * (1 - scale)
+ rect = f''
+ return (
+ '\n'
+ )
+
+
+def lockup(ink, node):
+ w = float(LOCKUP_VIEWBOX.split()[2]) / float(LOCKUP_VIEWBOX.split()[3]) * 48
+ return (
+ f'\n'
+ )
os.makedirs(OUT, exist_ok=True)
variants = {
- "mark.svg": svg(512, 0.86), # transparent, tight: favicons and the app header
- "icon.svg": svg(512, 0.667, background="#000"), # home-screen icon (iOS shows transparency as black)
- "maskable.svg": svg(512, 0.54, background="#000"), # Android maskable: inside the 80% safe zone
- "mono.svg": svg(512, 0.86, mono="#000"), # Safari pinned tab
- "logo-long.svg": wordmark(),
+ "favicon.svg": tile(0.7, 22), # B3Pay's favicon: the app icon tile, which holds at 16px
+ "icon.svg": tile(0.7, 0), # home-screen icon, full-bleed: iOS and Android round it themselves
+ "maskable.svg": tile(0.56, 0), # Android maskable: inside the 80% safe zone
+ "mono.svg": '\n", # Safari pinned tab: one ink, Safari colors it
+ "logo-long.svg": lockup(INK_LIGHT, NODE_LIGHT), # the README on light grounds
+ "logo-long-dark.svg": lockup(INK_DARK, NODE_DARK), # the README on dark grounds
}
for name, text in variants.items():
with open(os.path.join(OUT, name), "w") as f:
diff --git a/frontend/src/app/features.ts b/frontend/src/app/features.ts
index cc48fc3..6cefeeb 100644
--- a/frontend/src/app/features.ts
+++ b/frontend/src/app/features.ts
@@ -1,11 +1,29 @@
-/** What this deployment offers, from the backend's public `get_config`. */
+/** What this deployment and this browser offer. */
import { useClient } from "@ic-reactor/react"
import { useQuery } from "@tanstack/react-query"
+import { useEffect, useSyncExternalStore } from "react"
+import { detectLocalAi, localAiStatus, subscribeLocalAi, type LocalAiStatus } from "../lib/localAi"
import { backendOf } from "../reactor"
-/** Whether a controller switched the AI assistant on (it is off by default). */
+/** Whether a controller switched the canister's AI assistant on (it is off by default). */
export function useAiEnabled(): boolean {
const client = useClient()
const config = useQuery(client.queryOptions(backendOf(client), "get_config"))
return config.data?.ai_enabled === true
}
+
+/** The on-device model: which one this browser can run, and whether it is ready. */
+export function useLocalAi(): LocalAiStatus {
+ const status = useSyncExternalStore(subscribeLocalAi, localAiStatus, localAiStatus)
+ useEffect(() => {
+ void detectLocalAi()
+ }, [])
+ return status
+}
+
+/** Whether any assistant can run: on this device, or on the canister when it is switched on. */
+export function useAnyAi(): { local: LocalAiStatus; cloud: boolean; any: boolean } {
+ const local = useLocalAi()
+ const cloud = useAiEnabled()
+ return { local, cloud, any: cloud || (local.status !== "unavailable" && local.status !== "checking") }
+}
diff --git a/frontend/src/components/AiPanel.tsx b/frontend/src/components/AiPanel.tsx
index 04f2c07..25ca1b4 100644
--- a/frontend/src/components/AiPanel.tsx
+++ b/frontend/src/components/AiPanel.tsx
@@ -1,8 +1,9 @@
-import { Check, Copy, RotateCcw, Sparkles } from "lucide-react"
+import { Check, Copy, RotateCcw } from "lucide-react"
import { useClient } from "@ic-reactor/react"
import { useMutation, useQuery } from "@tanstack/react-query"
-import { useState } from "react"
+import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
+import { useAnyAi } from "../app/features"
import {
AI_ACTIONS,
LANGUAGES,
@@ -13,11 +14,14 @@ import {
type AiAction,
} from "../lib/ai"
import { errorMessage } from "../lib/errors"
+import { localAiError, runLocalAi } from "../lib/localAi"
+import { cleanLocalResult, localActionPrompt } from "../lib/localAiPrompts"
import { normalizeTags, type NoteContent } from "../lib/note"
import { AiConsentDialog } from "./AiConsentDialog"
import { Dialog } from "./Dialog"
+import { AiBlock, AiMemo, LocalAiSetup } from "./LocalAi"
import { Markdown } from "./Markdown"
-import { Button, Notice, Select, Spinner, cn } from "./ui"
+import { Button, Notice, Select, cn } from "./ui"
import { backendOf } from "../reactor"
export interface AiSelection {
@@ -26,6 +30,16 @@ export interface AiSelection {
text: string
}
+type Engine = "local" | "cloud"
+
+interface Run {
+ action: AiAction
+ engine: Engine
+ state: "drafting" | "ready" | "failed"
+ text: string
+ error?: string
+}
+
const HEADINGS: Partial> = {
Summarize: "Summary",
ActionItems: "Action items",
@@ -46,38 +60,83 @@ export function AiPanel({
selection: AiSelection | null
onApply: (patch: Partial) => void
}) {
- const [action, setAction] = useState(null)
+ const { local, cloud } = useAnyAi()
+ const localUsable = local.status !== "unavailable"
+ const [engine, setEngine] = useState(localUsable ? "local" : "cloud")
const [language, setLanguage] = useState("English")
- const [result, setResult] = useState(null)
+ const [run, setRun] = useState(null)
const [consentFor, setConsentFor] = useState(null)
+ const abort = useRef(null)
const client = useClient()
const backend = backendOf(client)
- const account = useQuery({ ...client.queryOptions(backend, "get_account"), enabled: open })
+ const account = useQuery({ ...client.queryOptions(backend, "get_account"), enabled: open && cloud })
// AI requests change nothing a read shows: invalidate nothing.
const assist = useMutation(client.mutationOptions(backend, "ai_assist", { invalidates: [] }))
- const maxBytes = account.data?.limits.max_ai_input_bytes ?? 8_000
+ // The on-device model is the default; the canister's is the choice when this browser can't run one.
+ useEffect(() => {
+ if (!localUsable && cloud) setEngine("cloud")
+ }, [localUsable, cloud])
+ useEffect(() => () => abort.current?.abort(), [])
+
+ const maxBytes = engine === "cloud" ? (account.data?.limits.max_ai_input_bytes ?? 8_000) : 6_000
const input = selection ? selection.text : noteAsPrompt(content.title, content.body)
const tooLong = encoder.encode(input).length > maxBytes
- const aiDisabled = account.data?.ai_enabled === false
+ const localReady = local.status === "ready"
- const run = async (chosen: AiAction) => {
+ const runCloud = async (chosen: AiAction) => {
if (!hasAiConsent()) {
setConsentFor(chosen)
return
}
- setAction(chosen)
- setResult(null)
+ setRun({ action: chosen, engine: "cloud", state: "drafting", text: "" })
try {
const reply = await assist.mutateAsync({ task: toAiTask(chosen.id, language), text: input })
- setResult(reply.text)
+ setRun({ action: chosen, engine: "cloud", state: "ready", text: reply.text })
} catch (error) {
- toast.error(errorMessage(error))
+ setRun({ action: chosen, engine: "cloud", state: "failed", text: "", error: errorMessage(error) })
}
}
+ const runLocal = async (chosen: AiAction) => {
+ abort.current?.abort()
+ const controller = new AbortController()
+ abort.current = controller
+ setRun({ action: chosen, engine: "local", state: "drafting", text: "" })
+ try {
+ const raw = await runLocalAi(
+ localActionPrompt(chosen.id, input, language),
+ controller.signal,
+ (text) => {
+ if (!controller.signal.aborted) setRun((r) => (r && r.state === "drafting" ? { ...r, text } : r))
+ },
+ )
+ if (controller.signal.aborted) return
+ const text = cleanLocalResult(chosen.id, raw)
+ setRun((r) =>
+ r
+ ? text
+ ? { ...r, state: "ready", text }
+ : { ...r, state: "failed", error: "The model gave back nothing. Try again." }
+ : r,
+ )
+ } catch (error) {
+ if (controller.signal.aborted) return
+ setRun((r) => (r ? { ...r, state: "failed", error: localAiError(error) } : r))
+ }
+ }
+
+ const start = (chosen: AiAction) => void (engine === "cloud" ? runCloud(chosen) : runLocal(chosen))
+
+ // Stopping keeps what was written so far, so it can still be used.
+ const stop = () => {
+ abort.current?.abort()
+ setRun((r) => (r && r.state === "drafting" ? (r.text.trim() ? { ...r, state: "ready" } : null) : r))
+ }
+
const apply = () => {
- if (!action || !result) return
+ if (!run || run.state !== "ready") return
+ const { action, text: result } = run
if (action.apply === "title") onApply({ title: result.split("\n")[0] })
else if (action.apply === "tags")
onApply({ tags: normalizeTags([...content.tags, ...parseTags(result)]) })
@@ -97,20 +156,26 @@ export function AiPanel({
}
const close = () => {
- setAction(null)
- setResult(null)
+ abort.current?.abort()
+ setRun(null)
onClose()
}
- const applyLabel = action
+ const applyLabel = run
? {
title: "Use as title",
tags: "Add tags",
replace: selection ? "Replace selection" : "Replace note",
append: "Add to note",
- }[action.apply]
+ }[run.action.apply]
: ""
+ const where =
+ run?.engine === "cloud"
+ ? "sent to the Internet Computer's LLM canister"
+ : "read on this device, nothing left it"
+ const model = run?.engine === "cloud" ? account.data?.ai_model : "model" in local ? local.model : undefined
+
return (
<>