Skip to content

Commit 283bdb4

Browse files
committed
feat: major web UI overhaul — 13 QoL features
Markdown rendering: - Integrate marked.js (CDN) for rich text rendering in assistant messages - Integrate DOMPurify (CDN) for XSS sanitization - Integrate highlight.js + marked-highlight (CDN) for syntax highlighting - Copy button on all code blocks Session management: - Sidebar with conversation list, new chat button - Switch between sessions, delete conversations - Preview text shown as chat title Settings panel: - View/edit provider, model, API key, base URL, max tokens, temperature - View/edit system prompt - Preserves env var references (e.g., $OPENAI_API_KEY) when saving - Settings saved to config.json on disk Self-update: - Check for updates via GitHub releases (go-selfupdate) - One-click upgrade with automatic restart - Safe handling of non-semver versions (e.g., 'dev') Additional features: - Export chat as Markdown - File upload (10MB limit, path traversal protection) - Token usage tracking display - Dark/light theme toggle with highlight.js theme switching - Notification sound toggle (Web Audio API) - Keyboard shortcuts (Ctrl+K, Ctrl+L, Ctrl+E, Esc) - Mobile responsive layout Security fixes: - XSS prevention in session list (data attributes instead of inline onclick) - HTML entity + quote escaping in escapeHtml() - Empty filename validation in upload handler - API key masking in settings GET response Backend changes: - New API endpoints: /api/settings, /api/sessions, /api/tokens, /api/version, /api/upgrade, /api/upload, /api/export - ListSessions and DeleteSession methods on memory.Store - OriginalAPIKey field in config to preserve env var references - Separated html.go from server.go for maintainability - Web server creation moved from agent to main.go
1 parent 88f3c81 commit 283bdb4

9 files changed

Lines changed: 1661 additions & 462 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,18 @@ docker run -p 3000:3000 \
8181
- **Multi-provider LLM gateway** — OpenAI, Anthropic, Google Gemini, OpenRouter, and any OpenAI-compatible API
8282
- **Persistent memory** — SQLite-backed conversation history that survives restarts
8383
- **Tool execution** — Sandboxed shell commands, file I/O, web search, HTTP requests
84-
- **Web chat UI** — Clean, embedded interface with zero external dependencies
84+
- **Web chat UI** — Full-featured embedded interface with Markdown rendering, syntax highlighting, and dark/light themes
85+
- **Session management** — Create, switch, and delete conversations with sidebar navigation
8586
- **In-browser logs viewer** — Slide-out panel with color-coded log levels, auto-refresh, no SSH needed
87+
- **Settings panel** — Change model, API key, temperature, system prompt from the UI (no SSH required)
88+
- **Self-update** — Check for and install updates directly from the web UI
89+
- **Copy code blocks** — One-click copy button on all code blocks in assistant responses
90+
- **Export chat** — Download conversations as Markdown files
91+
- **File upload** — Drag-and-drop files into the chat for the agent to process
92+
- **Token tracking** — Monitor cumulative token usage across sessions
93+
- **Keyboard shortcuts** — Ctrl+K (new chat), Ctrl+L (clear), Ctrl+E (export), Esc (close panels)
94+
- **Notification sound** — Optional audio ping when responses arrive
95+
- **Mobile responsive** — Full sidebar and panel support on mobile devices
8696

8797
### Deployment
8898
- **One-click GCP deploy** — Guided Cloud Shell tutorial with automated setup

cmd/pennyclaw/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func main() {
6565
}
6666

6767
// Start web server
68-
srv := web.NewServer(cfg.Server.Host, cfg.Server.Port, ag.HandleMessage)
68+
srv := web.NewServer(cfg.Server.Host, cfg.Server.Port, ag.HandleMessage, cfg, *configPath, ag.Memory(), version)
6969
go func() {
7070
log.Printf("PennyClaw %s starting on %s:%d", version, cfg.Server.Host, cfg.Server.Port)
7171
if err := srv.Start(); err != nil {

go.mod

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
module github.com/mandarl/pennyclaw
22

3-
go 1.22
3+
go 1.24.11
44

5-
require github.com/mattn/go-sqlite3 v1.14.22
5+
require (
6+
github.com/creativeprojects/go-selfupdate v1.5.2
7+
github.com/mattn/go-sqlite3 v1.14.22
8+
)
9+
10+
require (
11+
code.gitea.io/sdk/gitea v0.22.1 // indirect
12+
github.com/42wim/httpsig v1.2.3 // indirect
13+
github.com/Masterminds/semver/v3 v3.4.0 // indirect
14+
github.com/davidmz/go-pageant v1.0.2 // indirect
15+
github.com/go-fed/httpsig v1.1.0 // indirect
16+
github.com/google/go-github/v74 v74.0.0 // indirect
17+
github.com/google/go-querystring v1.1.0 // indirect
18+
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
19+
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
20+
github.com/hashicorp/go-version v1.8.0 // indirect
21+
github.com/ulikunitz/xz v0.5.15 // indirect
22+
gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect
23+
golang.org/x/crypto v0.46.0 // indirect
24+
golang.org/x/oauth2 v0.34.0 // indirect
25+
golang.org/x/sys v0.39.0 // indirect
26+
golang.org/x/time v0.14.0 // indirect
27+
gopkg.in/yaml.v3 v3.0.1 // indirect
28+
)

go.sum

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,72 @@
1+
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
2+
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
3+
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
4+
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
5+
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
6+
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
7+
github.com/creativeprojects/go-selfupdate v1.5.2 h1:3KR3JLrq70oplb9yZzbmJ89qRP78D1AN/9u+l3k0LJ4=
8+
github.com/creativeprojects/go-selfupdate v1.5.2/go.mod h1:BCOuwIl1dRRCmPNRPH0amULeZqayhKyY2mH/h4va7Dk=
9+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11+
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
12+
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
13+
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
14+
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
15+
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
16+
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
17+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
18+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
19+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
20+
github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM=
21+
github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak=
22+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
23+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
24+
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
25+
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
26+
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
27+
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
28+
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
29+
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
30+
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
31+
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
32+
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
33+
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
34+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
35+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
136
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
237
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
38+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
39+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
40+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
41+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
42+
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
43+
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
44+
gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8=
45+
gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME=
46+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
47+
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
48+
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
49+
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
50+
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
51+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
52+
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
53+
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
54+
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
55+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
56+
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
57+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
58+
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
59+
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
60+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
61+
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
62+
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
63+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
64+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
65+
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
66+
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
67+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
68+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
69+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
70+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
71+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
72+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/agent/agent.go

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/mandarl/pennyclaw/internal/memory"
1616
"github.com/mandarl/pennyclaw/internal/sandbox"
1717
"github.com/mandarl/pennyclaw/internal/skills"
18-
"github.com/mandarl/pennyclaw/internal/channels/web"
1918
)
2019

2120
// Agent is the core PennyClaw agent.
@@ -25,8 +24,6 @@ type Agent struct {
2524
memory *memory.Store
2625
sandbox *sandbox.Sandbox
2726
skills *skills.Registry
28-
webUI *web.Server
29-
3027
// supportsTools indicates whether the LLM provider supports tool/function calling.
3128
// Anthropic and Gemini providers currently operate in text-only mode.
3229
supportsTools bool
@@ -86,30 +83,8 @@ func New(cfg *config.Config) (*Agent, error) {
8683
}, nil
8784
}
8885

89-
// Start begins the agent's event loops and channels.
90-
func (a *Agent) Start(ctx context.Context) error {
91-
// Start web UI if enabled
92-
if a.cfg.Channels.Web.Enabled {
93-
a.webUI = web.NewServer(a.cfg.Server.Host, a.cfg.Server.Port, a.handleMessage)
94-
go func() {
95-
if err := a.webUI.Start(); err != nil {
96-
log.Printf("Web UI error: %v", err)
97-
}
98-
}()
99-
log.Printf("Web UI: http://%s:%d", a.cfg.Server.Host, a.cfg.Server.Port)
100-
}
101-
102-
// TODO: Start Telegram channel if enabled
103-
// TODO: Start Discord channel if enabled
104-
105-
return nil
106-
}
107-
10886
// Stop gracefully shuts down the agent.
10987
func (a *Agent) Stop() {
110-
if a.webUI != nil {
111-
a.webUI.Stop()
112-
}
11388
if a.memory != nil {
11489
a.memory.Close()
11590
}
@@ -216,6 +191,11 @@ func (a *Agent) HandleMessage(ctx context.Context, sessionID, message, channel s
216191
return a.handleMessage(ctx, sessionID, message, channel)
217192
}
218193

194+
// Memory returns the agent's memory store for use by other components.
195+
func (a *Agent) Memory() *memory.Store {
196+
return a.memory
197+
}
198+
219199
// HealthCheck returns the agent's health status.
220200
func (a *Agent) HealthCheck() map[string]interface{} {
221201
return map[string]interface{}{

0 commit comments

Comments
 (0)