Skip to content

Commit 9d2e0d9

Browse files
committed
Add durable feedback and live skill updates
1 parent 08a7f68 commit 9d2e0d9

15 files changed

Lines changed: 792 additions & 11 deletions

File tree

cloudflare/schema.sql

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,25 @@ CREATE TABLE IF NOT EXISTS workspaces (
1414
);
1515

1616
CREATE INDEX IF NOT EXISTS idx_workspaces_installation ON workspaces(installation_id);
17+
18+
CREATE TABLE IF NOT EXISTS feedback_reports (
19+
public_id TEXT PRIMARY KEY,
20+
installation_id TEXT NOT NULL,
21+
workspace_name TEXT NOT NULL DEFAULT '',
22+
comment TEXT NOT NULL,
23+
diagnostics TEXT NOT NULL DEFAULT '{}',
24+
attachment_count INTEGER NOT NULL DEFAULT 0,
25+
created_at INTEGER NOT NULL,
26+
received_at INTEGER NOT NULL
27+
);
28+
CREATE INDEX IF NOT EXISTS idx_feedback_received ON feedback_reports(received_at DESC);
29+
30+
CREATE TABLE IF NOT EXISTS feedback_attachments (
31+
id INTEGER PRIMARY KEY AUTOINCREMENT,
32+
report_id TEXT NOT NULL REFERENCES feedback_reports(public_id) ON DELETE CASCADE,
33+
name TEXT NOT NULL,
34+
mime TEXT NOT NULL,
35+
size INTEGER NOT NULL,
36+
data TEXT NOT NULL,
37+
created_at INTEGER NOT NULL
38+
);

cloudflare/src/worker.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ interface Env {
44
CLOUDFLARE_ZONE_ID: string;
55
CLOUDFLARE_RUNTIME_TOKEN: string;
66
PROVISION_LIMIT?: RateLimit;
7+
FEEDBACK_LIMIT?: RateLimit;
8+
FEEDBACK_ADMIN_TOKEN?: string;
79
}
810

911
type WorkspaceRow = {
@@ -151,11 +153,72 @@ async function workspaceAction(request: Request, env: Env, slug: string): Promis
151153
return json({ workspace: { slug, hostname: workspace.hostname, status: workspace.status, enabled } });
152154
}
153155

156+
async function feedbackIntake(request: Request, env: Env): Promise<Response> {
157+
const address = request.headers.get("cf-connecting-ip") || "unknown";
158+
if (env.FEEDBACK_LIMIT && !(await env.FEEDBACK_LIMIT.limit({ key: address })).success) {
159+
return json({ error: "Too many feedback reports. Try again shortly." }, 429);
160+
}
161+
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
162+
const publicId = String(body.public_id || "");
163+
const installationId = String(body.installation_id || "");
164+
const workspaceName = String(body.workspace_name || "").trim().slice(0, 100);
165+
const comment = String(body.comment || "").trim().slice(0, 10_000);
166+
const diagnostics = body.diagnostics && typeof body.diagnostics === "object" && !Array.isArray(body.diagnostics) ? body.diagnostics : {};
167+
const attachments = Array.isArray(body.attachments) ? body.attachments.slice(0, 3) as Array<Record<string, unknown>> : [];
168+
if (!/^fb_[a-f0-9]{24}$/.test(publicId) || !/^[a-f0-9]{16}$/.test(installationId)) {
169+
return json({ error: "Feedback source could not be verified." }, 400);
170+
}
171+
if (!comment && !attachments.length) return json({ error: "Feedback is empty." }, 400);
172+
if (JSON.stringify(diagnostics).length > 64 * 1024) return json({ error: "Diagnostics are too large." }, 413);
173+
let total = 0;
174+
for (const attachment of attachments) {
175+
const size = Number(attachment.size || 0);
176+
const data = String(attachment.data || "");
177+
if (size < 0 || size > 5 * 1024 * 1024 || data.length > 7 * 1024 * 1024) {
178+
return json({ error: "A feedback attachment is too large." }, 413);
179+
}
180+
total += size;
181+
}
182+
if (total > 10 * 1024 * 1024) return json({ error: "Feedback attachments are too large." }, 413);
183+
const timestamp = Date.now();
184+
await env.REGISTRY.prepare(`INSERT OR IGNORE INTO feedback_reports
185+
(public_id,installation_id,workspace_name,comment,diagnostics,attachment_count,created_at,received_at)
186+
VALUES (?,?,?,?,?,?,?,?)`).bind(publicId, installationId, workspaceName, comment, JSON.stringify(diagnostics), attachments.length, timestamp, timestamp).run();
187+
const exists = await env.REGISTRY.prepare("SELECT 1 FROM feedback_attachments WHERE report_id=? LIMIT 1").bind(publicId).first();
188+
if (!exists) {
189+
for (const attachment of attachments) await env.REGISTRY.prepare(`INSERT INTO feedback_attachments
190+
(report_id,name,mime,size,data,created_at) VALUES (?,?,?,?,?,?)`).bind(
191+
publicId,
192+
String(attachment.name || "attachment").slice(0, 255),
193+
String(attachment.mime || "application/octet-stream").slice(0, 120),
194+
Number(attachment.size || 0),
195+
String(attachment.data || ""),
196+
timestamp,
197+
).run();
198+
}
199+
return json({ id: publicId }, 202);
200+
}
201+
202+
async function feedbackInbox(request: Request, env: Env): Promise<Response> {
203+
const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || "";
204+
if (!env.FEEDBACK_ADMIN_TOKEN || token !== env.FEEDBACK_ADMIN_TOKEN) return json({ error: "Not found" }, 404);
205+
const results = await env.REGISTRY.prepare(`SELECT public_id,installation_id,workspace_name,comment,diagnostics,
206+
attachment_count,created_at created,received_at FROM feedback_reports ORDER BY received_at DESC LIMIT 500`).all<Record<string, unknown>>();
207+
return json({ reports: (results.results || []).map((report) => ({
208+
...report,
209+
diagnostics: JSON.parse(String(report.diagnostics || "{}")),
210+
state: "delivered",
211+
attachments: [],
212+
})) });
213+
}
214+
154215
export default {
155216
async fetch(request: Request, env: Env): Promise<Response> {
156217
if (request.method === "OPTIONS") return json({ ok: true });
157218
const url = new URL(request.url);
158219
if (url.pathname === "/health") return json({ ok: true });
220+
if (url.pathname === "/v1/feedback" && request.method === "POST") return feedbackIntake(request, env);
221+
if (url.pathname === "/v1/feedback" && request.method === "GET") return feedbackInbox(request, env);
159222
const availability = url.pathname.match(/^\/v1\/slugs\/([a-z0-9-]+)$/);
160223
if (availability && request.method === "GET") {
161224
const slug = availability[1].toLowerCase();

cloudflare/wrangler.jsonc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
"name": "PROVISION_LIMIT",
1212
"namespace_id": "1001",
1313
"simple": { "limit": 20, "period": 60 }
14+
},
15+
{
16+
"name": "FEEDBACK_LIMIT",
17+
"namespace_id": "1002",
18+
"simple": { "limit": 30, "period": 60 }
1419
}
1520
],
1621
"d1_databases": [

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@
2727
"test:brief-browser": "node test/brief-regressions-browser.mjs",
2828
"test:production-browser": "node test/production-browser.mjs",
2929
"test:terminal-browser": "node --test test/terminal-reconnect-browser.mjs",
30+
"test:feedback-browser": "node --test test/feedback-browser.mjs",
3031
"test:site": "node --test test/site.mjs",
3132
"benchmark:autonomy": "node scripts/autonomy-benchmark.mjs",
3233
"helm": "node scripts/1helm-cli.mjs",
3334
"test:live": "node test/live-smoke.mjs",
34-
"test": "node test/native-world.mjs && node --test test/routing.mjs test/routing-disabled-account.mjs test/desktop.mjs test/update-service.mjs test/channel-computers.mjs test/cloudflare-worker.mjs test/connectors.mjs test/chatgpt-image.mjs test/autonomy-platform.mjs test/gmail.mjs test/photon.mjs test/site.mjs test/terminal-reconnect-contract.mjs test/terminal-reconnect-browser.mjs test/web-research.mjs test/workflows.mjs",
35+
"test": "node test/native-world.mjs && node --test test/routing.mjs test/routing-disabled-account.mjs test/desktop.mjs test/update-service.mjs test/channel-computers.mjs test/cloudflare-worker.mjs test/connectors.mjs test/chatgpt-image.mjs test/autonomy-platform.mjs test/feedback.mjs test/feedback-browser.mjs test/gmail.mjs test/photon.mjs test/site.mjs test/terminal-reconnect-contract.mjs test/terminal-reconnect-browser.mjs test/web-research.mjs test/workflows.mjs",
3536
"ci": "npm run typecheck && npm run build && npm test",
3637
"test:pipeline": "node test/pipeline.mjs",
3738
"desktop": "electron .",

public/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@
2929
document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); });
3030
})();
3131
</script>
32-
<link rel="stylesheet" href="/app.css?v=13afbaf9cae5" />
32+
<link rel="stylesheet" href="/app.css?v=78175b6a3067" />
3333
<link rel="stylesheet" href="/bundle.css" />
3434
</head>
3535
<body class="h-screen w-screen overflow-hidden antialiased">
3636
<div id="app" class="h-full w-full"></div>
37-
<script type="module" src="/bundle.js?v=b053ad3e9c4b"></script>
37+
<script type="module" src="/bundle.js?v=a4d940f88c26"></script>
3838
</body>
3939
</html>

src/client/app.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadUsage, type RoutingModel } from "./api.ts";
22
import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, beep, icon, helmMark, type ChannelLink } from "./dom.ts";
3-
import { openSettings, finishOpenRouterOAuth } from "./settings.ts";
3+
import { openSettings, finishOpenRouterOAuth, refreshOpenSkillsSettings } from "./settings.ts";
44
import { pushRoutingActivity } from "./routing.ts";
55
import { openOnboarding } from "./onboarding.ts";
66
import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts";
@@ -488,6 +488,13 @@ function onEvent(e: any): void {
488488
if (S.view === "settings") renderChannelView();
489489
} else if (e.type === "providers_changed") {
490490
void reloadProviders().then(() => { if (S.view === "settings") renderChannelView(); });
491+
} else if (e.type === "skills_changed") {
492+
void loadWorkspace().then(() => {
493+
renderSidebar();
494+
renderHeader();
495+
if (S.view === "settings") renderChannelView();
496+
refreshOpenSkillsSettings();
497+
});
491498
} else if (e.type === "bot_update" && e.bot) {
492499
const i = S.bots.findIndex((b) => b.id === e.bot.id);
493500
if (i >= 0) S.bots[i] = e.bot; else S.bots = [...S.bots, e.bot];
@@ -777,13 +784,111 @@ function sidebar(drawer = false): HTMLElement {
777784
archived.length ? h("div", {}, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Archived"), h("div", { class: "space-y-px opacity-65" }, ...archived.map(chan))) : null,
778785
collab.length ? h("div", {}, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Human space"), h("div", { class: "space-y-px" }, ...collab.map(chan))) : null,
779786
h("div", {}, sbSection("Direct messages", () => newDM()), h("div", { class: "space-y-px" }, ...dms.map(chan), dms.length === 0 && h("p", { class: "px-2 py-1 text-[13px] text-sidebar-muted" }, "No conversations yet")))),
787+
h("button", {
788+
class: "mx-2 mb-1 flex min-h-10 items-center gap-2 rounded-md px-2 py-1.5 text-xs text-sidebar-muted hover:bg-sidebar-hover hover:text-white",
789+
type: "button",
790+
dataset: { feedbackAction: "" },
791+
onclick: () => { closeMobileMenu(); openFeedback(); },
792+
}, icon("chat", 14), "Feedback"),
780793
h("div", { class: "flex items-center gap-1 border-t border-white/10 p-1.5" },
781794
h("button", { class: "flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-sidebar-hover", title: "Open profile", onclick: (event: MouseEvent) => { closeMobileMenu(); openProfile(event.currentTarget as HTMLElement); } },
782795
avatar(S.me.display, "user", 8, S.me.avatar),
783796
h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-white" }, S.me.display), h("div", { class: "flex items-center gap-1.5 truncate font-mono text-[10.5px] text-sidebar-muted" }, h("span", { class: "h-1.5 w-1.5 rounded-full bg-ok" }), "@" + S.me.username + (S.me.is_admin ? " · admin" : "")))),
784797
h("button", { class: "grid h-10 w-10 shrink-0 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: S.me.is_admin ? "Settings" : "Provider settings", "aria-label": "Open settings", onclick: () => { closeMobileMenu(); openSettings(S.me.is_admin ? "agents" : "providers"); } }, icon("gear"))));
785798
}
786799

800+
function openFeedback(): void {
801+
document.getElementById("feedback-modal")?.remove();
802+
const overlay = h("div", {
803+
id: "feedback-modal",
804+
class: "modal-overlay fixed inset-0 z-[90] grid place-items-center bg-black/70 p-3",
805+
role: "dialog",
806+
"aria-modal": "true",
807+
"aria-label": "Send feedback",
808+
});
809+
const comment = h("textarea", {
810+
class: "field min-h-36 resize-y",
811+
maxlength: 10000,
812+
placeholder: "What happened? What did you expect?",
813+
dataset: { feedbackComment: "" },
814+
}) as HTMLTextAreaElement;
815+
const picker = h("input", {
816+
type: "file",
817+
multiple: true,
818+
class: "hidden",
819+
accept: "image/*,application/pdf,text/plain,application/json",
820+
dataset: { feedbackFiles: "" },
821+
}) as HTMLInputElement;
822+
const fileList = h("div", { class: "space-y-1 text-xs text-muted" });
823+
const diagnostics = h("input", {
824+
type: "checkbox",
825+
checked: false,
826+
class: "accent-accent",
827+
dataset: { feedbackDiagnostics: "" },
828+
}) as HTMLInputElement;
829+
const status = h("p", { class: "min-h-5 text-sm text-muted", dataset: { feedbackStatus: "" } });
830+
let files: File[] = [];
831+
const showFiles = (): void => {
832+
clear(fileList);
833+
fileList.append(...files.map((file) => h("div", { class: "truncate" }, `${file.name} · ${Math.ceil(file.size / 1024)} KB`)));
834+
};
835+
picker.onchange = () => {
836+
const selected = [...(picker.files || [])].slice(0, 3);
837+
if (selected.some((file) => file.size > 5 * 1024 * 1024) || selected.reduce((sum, file) => sum + file.size, 0) > 10 * 1024 * 1024) {
838+
status.textContent = "Attachments are limited to 5 MB each and 10 MB total.";
839+
return;
840+
}
841+
files = selected;
842+
status.textContent = "";
843+
showFiles();
844+
};
845+
const close = (): void => overlay.remove();
846+
const submit = h("button", {
847+
class: "btn-primary text-sm",
848+
type: "button",
849+
dataset: { feedbackSubmit: "" },
850+
onclick: async () => {
851+
submit.disabled = true;
852+
status.textContent = "Saving feedback…";
853+
try {
854+
const uploads = [];
855+
for (const file of files) uploads.push(await uploadFile(file));
856+
const result = await api<{ feedback: { id: string; state: string } }>("/api/feedback", {
857+
body: { comment: comment.value, send_diagnostics: diagnostics.checked, uploads },
858+
});
859+
status.textContent = `Saved as ${result.feedback.id}. Delivery will retry automatically if this host is offline.`;
860+
setTimeout(close, 1400);
861+
} catch (error) {
862+
status.textContent = (error as Error).message;
863+
submit.disabled = false;
864+
}
865+
},
866+
}, "Send feedback") as HTMLButtonElement;
867+
const card = h("section", { class: "card w-full max-w-xl space-y-4 p-5 shadow-2xl" },
868+
h("div", { class: "flex items-start justify-between gap-3" },
869+
h("div", {},
870+
h("h2", { class: "font-display text-xl text-fg" }, "Send feedback"),
871+
h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Tell us what feels broken or what would make 1Helm better.")),
872+
h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close feedback", onclick: close }, icon("x"))),
873+
comment,
874+
h("div", {},
875+
h("button", { class: "btn-subtle text-sm", type: "button", onclick: () => picker.click() }, "Add attachments"),
876+
picker,
877+
fileList),
878+
h("label", { class: "flex items-start gap-2 rounded-lg border border-line bg-panel p-3 text-sm text-fg" },
879+
diagnostics,
880+
h("span", {}, "Include privacy-bounded diagnostics",
881+
h("span", { class: "mt-1 block text-xs leading-5 text-muted" }, "Optional. Includes app version, platform, runtime health, failed capability names/statuses, and connector health. Never includes chats, prompts, account content, terminal output, tokens, keys, or OAuth data."))),
882+
status,
883+
h("div", { class: "flex justify-end gap-2" },
884+
h("button", { class: "btn-subtle text-sm", onclick: close }, "Cancel"),
885+
submit));
886+
overlay.onclick = (event) => { if (event.target === overlay) close(); };
887+
overlay.append(card);
888+
document.body.append(overlay);
889+
comment.focus();
890+
}
891+
787892
function openProfile(anchor: HTMLElement): void {
788893
document.getElementById("profile-popover")?.remove();
789894
const display = h("input", { class: "field", value: S.me.display, maxlength: 100 }) as HTMLInputElement;

src/client/channel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel,
716716
await api(`/api/channels/${channel.id}`, { method: "DELETE", body: { confirm: confirmation } }); onChanged(true);
717717
} }, icon("trash", 14), "Delete permanently") : null));
718718

719-
const assignedSkills = h("div", { class: "mt-3 flex flex-wrap gap-2" }, ...((channel.agent?.skills || []).map((skill) => h("span", { class: "chip border-accent/25" }, skill.name))));
719+
const assignedSkills = h("div", { class: "mt-3 flex flex-wrap gap-2", dataset: { assignedSkills: "" } }, ...((channel.agent?.skills || []).map((skill) => h("span", { class: "chip border-accent/25", dataset: { assignedSkill: skill.slug } }, skill.name))));
720720
const computer = channel.computer;
721721
const computerCard = computer ? h("div", { class: "card p-4" },
722722
h("div", { class: "flex flex-wrap items-center gap-2" },

0 commit comments

Comments
 (0)