@tangle-network/create-agent-app 0.44.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/index.mjs +183 -0
- package/package.json +34 -0
- package/template/.dev.vars.example +11 -0
- package/template/AGENTS.md +67 -0
- package/template/CLAUDE.md +6 -0
- package/template/CUSTOMIZE.md +83 -0
- package/template/KNOWLEDGE.md +73 -0
- package/template/README.md +41 -0
- package/template/_gitignore +5 -0
- package/template/_package.json +33 -0
- package/template/_tsconfig.json +16 -0
- package/template/_wrangler.toml +22 -0
- package/template/agent.config.ts +113 -0
- package/template/knowledge/.gitkeep +0 -0
- package/template/knowledge/README.md +24 -0
- package/template/scripts/knowledge-ingest.mjs +86 -0
- package/template/src/agent-app.ts +77 -0
- package/template/src/worker.ts +128 -0
- package/template/tests/agent-app.test.ts +71 -0
- package/template/vitest.config.ts +7 -0
- package/template-chat/.dev.vars.example +18 -0
- package/template-chat/AGENTS.md +73 -0
- package/template-chat/CLAUDE.md +6 -0
- package/template-chat/CUSTOMIZE.md +88 -0
- package/template-chat/README.md +50 -0
- package/template-chat/_gitignore +6 -0
- package/template-chat/_package.json +38 -0
- package/template-chat/_tsconfig.json +16 -0
- package/template-chat/_wrangler.toml +38 -0
- package/template-chat/agent.config.ts +73 -0
- package/template-chat/declarations.d.ts +9 -0
- package/template-chat/migrations/0001_init.sql +109 -0
- package/template-chat/prompts/system.md +11 -0
- package/template-chat/public/index.html +236 -0
- package/template-chat/src/chat.ts +210 -0
- package/template-chat/src/db/schema.ts +70 -0
- package/template-chat/src/env.ts +33 -0
- package/template-chat/src/sandbox.ts +159 -0
- package/template-chat/src/worker.ts +58 -0
- package/template-chat/tests/chat-turn.e2e.test.ts +277 -0
- package/template-chat/vitest.config.ts +22 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>__PROJECT_NAME__ — dev chat</title>
|
|
7
|
+
<!--
|
|
8
|
+
The DEV chat page: a dependency-free client for the API this worker ships,
|
|
9
|
+
good enough to exercise auth, threads, multimodal turns, and streaming from
|
|
10
|
+
a browser today. It is NOT the product surface — build that in React with
|
|
11
|
+
@tangle-network/agent-app/web-react (ChatComposer, ChatMessages,
|
|
12
|
+
streamChatTurn, useChatInteractions), which owns reconnect/replay, question
|
|
13
|
+
cards, and the typed part renderers. See CUSTOMIZE.md step 5.
|
|
14
|
+
-->
|
|
15
|
+
<style>
|
|
16
|
+
:root { color-scheme: light dark; --border: #d0d3da; --muted: #6b7280; --accent: #4f46e5; }
|
|
17
|
+
@media (prefers-color-scheme: dark) { :root { --border: #333842; --muted: #9aa1ad; --accent: #818cf8; } }
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; display: flex; height: 100dvh; }
|
|
20
|
+
aside { width: 240px; border-right: 1px solid var(--border); padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
21
|
+
main { flex: 1; display: flex; flex-direction: column; }
|
|
22
|
+
header { padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
|
23
|
+
#transcript { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
24
|
+
.msg { max-width: 52rem; white-space: pre-wrap; }
|
|
25
|
+
.msg.user { align-self: flex-end; background: color-mix(in srgb, var(--accent) 12%, transparent); border-radius: 10px; padding: 8px 12px; }
|
|
26
|
+
.msg .meta { font-size: 12px; color: var(--muted); }
|
|
27
|
+
.chip { display: inline-block; font-size: 12px; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; margin: 2px 4px 2px 0; color: var(--muted); }
|
|
28
|
+
.reasoning { color: var(--muted); font-size: 13px; }
|
|
29
|
+
form.composer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
|
30
|
+
textarea { flex: 1; resize: none; font: inherit; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: inherit; }
|
|
31
|
+
button { font: inherit; border: 1px solid var(--border); background: transparent; color: inherit; border-radius: 8px; padding: 6px 12px; cursor: pointer; }
|
|
32
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
33
|
+
.thread { text-align: left; border: none; padding: 6px 8px; border-radius: 6px; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
34
|
+
.thread.active { background: color-mix(in srgb, var(--accent) 15%, transparent); }
|
|
35
|
+
dialog { border: 1px solid var(--border); border-radius: 12px; padding: 20px; min-width: 320px; }
|
|
36
|
+
dialog form { display: flex; flex-direction: column; gap: 10px; }
|
|
37
|
+
dialog input { font: inherit; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: inherit; }
|
|
38
|
+
#files { padding: 0 16px; }
|
|
39
|
+
.error { color: #dc2626; font-size: 13px; padding: 0 16px; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<aside>
|
|
44
|
+
<button id="new-thread" class="primary">New thread</button>
|
|
45
|
+
<nav id="threads" style="overflow-y:auto"></nav>
|
|
46
|
+
</aside>
|
|
47
|
+
<main>
|
|
48
|
+
<header>
|
|
49
|
+
<strong>__PROJECT_NAME__ <span style="color:var(--muted);font-weight:400">dev chat</span></strong>
|
|
50
|
+
<span id="who" style="color:var(--muted);font-size:13px"></span>
|
|
51
|
+
</header>
|
|
52
|
+
<div id="transcript"></div>
|
|
53
|
+
<div id="files"></div>
|
|
54
|
+
<div id="error" class="error"></div>
|
|
55
|
+
<form class="composer" id="composer">
|
|
56
|
+
<button type="button" id="attach" title="Attach files">+</button>
|
|
57
|
+
<input type="file" id="file-input" multiple hidden />
|
|
58
|
+
<textarea id="input" rows="2" placeholder="Message the agent…"></textarea>
|
|
59
|
+
<button type="submit" class="primary">Send</button>
|
|
60
|
+
</form>
|
|
61
|
+
</main>
|
|
62
|
+
|
|
63
|
+
<dialog id="auth">
|
|
64
|
+
<form method="dialog" id="auth-form">
|
|
65
|
+
<strong id="auth-title">Sign in</strong>
|
|
66
|
+
<input id="email" type="email" placeholder="email" required autocomplete="email" />
|
|
67
|
+
<input id="password" type="password" placeholder="password" required minlength="8" autocomplete="current-password" />
|
|
68
|
+
<button class="primary" value="submit">Continue</button>
|
|
69
|
+
<button type="button" id="auth-toggle" style="border:none;color:var(--muted)">No account? Sign up</button>
|
|
70
|
+
<span id="auth-error" class="error" style="padding:0"></span>
|
|
71
|
+
</form>
|
|
72
|
+
</dialog>
|
|
73
|
+
|
|
74
|
+
<script type="module">
|
|
75
|
+
const $ = (id) => document.getElementById(id)
|
|
76
|
+
const state = { threadId: null, parts: [], signUp: false }
|
|
77
|
+
|
|
78
|
+
const api = async (path, init) => {
|
|
79
|
+
const res = await fetch(path, { headers: { 'content-type': 'application/json' }, ...init })
|
|
80
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? `${res.status} on ${path}`)
|
|
81
|
+
return res.json()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── auth ────────────────────────────────────────────────────────────────────
|
|
85
|
+
async function ensureSession() {
|
|
86
|
+
const session = await fetch('/api/auth/get-session').then((r) => r.json()).catch(() => null)
|
|
87
|
+
if (session?.user) { $('who').textContent = session.user.email; return }
|
|
88
|
+
$('auth').showModal()
|
|
89
|
+
await new Promise((resolve) => {
|
|
90
|
+
$('auth-toggle').onclick = () => {
|
|
91
|
+
state.signUp = !state.signUp
|
|
92
|
+
$('auth-title').textContent = state.signUp ? 'Sign up' : 'Sign in'
|
|
93
|
+
$('auth-toggle').textContent = state.signUp ? 'Have an account? Sign in' : 'No account? Sign up'
|
|
94
|
+
}
|
|
95
|
+
$('auth-form').onsubmit = async (e) => {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
const email = $('email').value, password = $('password').value
|
|
98
|
+
try {
|
|
99
|
+
await api(`/api/auth/sign-${state.signUp ? 'up' : 'in'}/email`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: JSON.stringify({ email, password, ...(state.signUp ? { name: email.split('@')[0] } : {}) }),
|
|
102
|
+
})
|
|
103
|
+
$('who').textContent = email
|
|
104
|
+
$('auth').close(); resolve()
|
|
105
|
+
} catch (err) { $('auth-error').textContent = err.message }
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── threads + transcript ────────────────────────────────────────────────────
|
|
111
|
+
async function loadThreads() {
|
|
112
|
+
const { threads } = await api('/api/threads')
|
|
113
|
+
$('threads').replaceChildren(...threads.map((t) => {
|
|
114
|
+
const b = document.createElement('button')
|
|
115
|
+
b.className = `thread${t.id === state.threadId ? ' active' : ''}`
|
|
116
|
+
b.textContent = t.title
|
|
117
|
+
b.onclick = () => openThread(t.id)
|
|
118
|
+
return b
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function row(role, text = '') {
|
|
123
|
+
const div = document.createElement('div')
|
|
124
|
+
div.className = `msg ${role}`
|
|
125
|
+
div.textContent = text
|
|
126
|
+
$('transcript').append(div)
|
|
127
|
+
div.scrollIntoView()
|
|
128
|
+
return div
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Chips are built with textContent — part fields are agent-produced strings
|
|
132
|
+
// and must never be interpreted as markup.
|
|
133
|
+
function chip(container, text) {
|
|
134
|
+
const wrap = document.createElement('div')
|
|
135
|
+
const span = document.createElement('span')
|
|
136
|
+
span.className = 'chip'
|
|
137
|
+
span.textContent = text
|
|
138
|
+
wrap.append(span)
|
|
139
|
+
container.append(wrap)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderParts(container, parts = []) {
|
|
143
|
+
for (const p of parts) {
|
|
144
|
+
if (p.type === 'tool') chip(container, `${p.tool ?? 'tool'} · ${p.state?.status ?? ''}`)
|
|
145
|
+
if (p.type === 'file' || p.type === 'image') chip(container, `📎 ${p.filename ?? p.path ?? p.type}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function openThread(id) {
|
|
150
|
+
state.threadId = id
|
|
151
|
+
const { messages } = await api(`/api/threads/${id}/messages`)
|
|
152
|
+
$('transcript').replaceChildren()
|
|
153
|
+
for (const m of messages) {
|
|
154
|
+
const div = row(m.role, m.content)
|
|
155
|
+
renderParts(div, m.parts)
|
|
156
|
+
if (m.role === 'assistant' && m.inputTokens != null) {
|
|
157
|
+
const meta = document.createElement('div')
|
|
158
|
+
meta.className = 'meta'
|
|
159
|
+
meta.textContent = `${m.model ?? ''} · ${m.inputTokens}→${m.outputTokens} tokens`
|
|
160
|
+
div.append(meta)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
loadThreads()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── uploads → prompt parts ──────────────────────────────────────────────────
|
|
167
|
+
$('attach').onclick = () => $('file-input').click()
|
|
168
|
+
$('file-input').onchange = async () => {
|
|
169
|
+
const form = new FormData()
|
|
170
|
+
for (const f of $('file-input').files) form.append('files', f)
|
|
171
|
+
const res = await fetch('/api/chat/upload', { method: 'POST', body: form })
|
|
172
|
+
if (!res.ok) { $('error').textContent = (await res.json()).error; return }
|
|
173
|
+
const { files } = await res.json()
|
|
174
|
+
state.parts.push(...files.map((f) => f.part))
|
|
175
|
+
$('files').replaceChildren(...state.parts.map((p) => Object.assign(document.createElement('span'), { className: 'chip', textContent: p.filename ?? p.path ?? p.type })))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── one streamed turn ───────────────────────────────────────────────────────
|
|
179
|
+
// Minimal NDJSON consumer for the dev page. The production client is
|
|
180
|
+
// streamChatTurn from @tangle-network/agent-app/web-react — it also resumes
|
|
181
|
+
// via /api/chat/replay/:turnId after a dropped connection.
|
|
182
|
+
async function runTurn(content, parts) {
|
|
183
|
+
const res = await fetch('/api/chat', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'content-type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ threadId: state.threadId, content, parts }),
|
|
187
|
+
})
|
|
188
|
+
if (!res.ok) { $('error').textContent = (await res.json()).error; return }
|
|
189
|
+
const assistant = row('assistant')
|
|
190
|
+
let text = ''
|
|
191
|
+
const reader = res.body.getReader()
|
|
192
|
+
const decoder = new TextDecoder()
|
|
193
|
+
let buffer = ''
|
|
194
|
+
const handle = (line) => {
|
|
195
|
+
if (!line.trim()) return
|
|
196
|
+
let parsed; try { parsed = JSON.parse(line) } catch { return }
|
|
197
|
+
const evt = parsed.kind === 'event' ? parsed.event : parsed
|
|
198
|
+
if (evt.type === 'text') { text += evt.text; assistant.textContent = text }
|
|
199
|
+
else if (evt.type === 'reasoning') { /* keep the dev page quiet; web-react renders these */ }
|
|
200
|
+
else if (evt.type === 'tool_call') chip(assistant, `${evt.call?.toolName ?? 'tool'}…`)
|
|
201
|
+
else if (evt.type === 'error') $('error').textContent = String(evt.details ?? evt.error)
|
|
202
|
+
assistant.scrollIntoView()
|
|
203
|
+
}
|
|
204
|
+
for (;;) {
|
|
205
|
+
const { done, value } = await reader.read()
|
|
206
|
+
if (done) { if (buffer.trim()) handle(buffer); break }
|
|
207
|
+
buffer += decoder.decode(value, { stream: true })
|
|
208
|
+
const lines = buffer.split('\n'); buffer = lines.pop() ?? ''
|
|
209
|
+
lines.forEach(handle)
|
|
210
|
+
}
|
|
211
|
+
openThread(state.threadId)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
$('composer').onsubmit = async (e) => {
|
|
215
|
+
e.preventDefault()
|
|
216
|
+
const content = $('input').value.trim()
|
|
217
|
+
const parts = state.parts.splice(0)
|
|
218
|
+
if (!content && parts.length === 0) return
|
|
219
|
+
$('error').textContent = ''
|
|
220
|
+
$('files').replaceChildren()
|
|
221
|
+
$('input').value = ''
|
|
222
|
+
if (!state.threadId) {
|
|
223
|
+
const { thread } = await api('/api/threads', { method: 'POST', body: JSON.stringify({ firstMessage: content || 'New thread' }) })
|
|
224
|
+
state.threadId = thread.id
|
|
225
|
+
}
|
|
226
|
+
row('user', content)
|
|
227
|
+
await runTurn(content, parts).catch((err) => { $('error').textContent = err.message })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
$('new-thread').onclick = () => { state.threadId = null; $('transcript').replaceChildren() }
|
|
231
|
+
|
|
232
|
+
await ensureSession()
|
|
233
|
+
await loadThreads()
|
|
234
|
+
</script>
|
|
235
|
+
</body>
|
|
236
|
+
</html>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/chat.ts — the COMPOSER. The whole server chat vertical assembled from
|
|
3
|
+
* `@tangle-network/agent-app` factories, exactly the `examples/chat-app.md`
|
|
4
|
+
* assembly made runnable:
|
|
5
|
+
*
|
|
6
|
+
* auth `createAppAuth` (better-auth over drizzle/D1) + its guards
|
|
7
|
+
* persistence `createChatStore` over the tables in `src/db/schema.ts`
|
|
8
|
+
* turn `createChatTurnRoutes` — body validation, turn identity,
|
|
9
|
+
* the default turn-buffer tap (replay after a drop), user and
|
|
10
|
+
* assistant rows persisted with typed parts + usage receipt
|
|
11
|
+
* producer the sandbox lane from `src/sandbox.ts`
|
|
12
|
+
* uploads `createUploadRoute` — small files inline (`data:` URI),
|
|
13
|
+
* large files into the sandbox workspace by path
|
|
14
|
+
* asks `/interactions` list/answer endpoints over the sidecar
|
|
15
|
+
*
|
|
16
|
+
* You extend THIS file (and `src/worker.ts`); you never edit the shell. The
|
|
17
|
+
* `overrides` seams exist so the e2e test in `tests/` can run the identical
|
|
18
|
+
* assembly against an in-memory database and a fake sandbox producer — the
|
|
19
|
+
* production wiring is the zero-override call.
|
|
20
|
+
*
|
|
21
|
+
* Workspaces are single-user here (workspace id = user id). Multi-user teams
|
|
22
|
+
* later: `@tangle-network/agent-app/teams` + `createChatTables({ workspaceTable })`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { config } from '../agent.config'
|
|
26
|
+
import { createAppAuth, type AppAuth } from '@tangle-network/agent-app/app-auth'
|
|
27
|
+
import {
|
|
28
|
+
createChatTurnRoutes,
|
|
29
|
+
createUploadRoute,
|
|
30
|
+
type ChatTurnAuthorization,
|
|
31
|
+
type ChatTurnProduceArgs,
|
|
32
|
+
type ChatTurnRouteProducer,
|
|
33
|
+
type ChatTurnRoutes,
|
|
34
|
+
type SandboxUploadSink,
|
|
35
|
+
} from '@tangle-network/agent-app/chat-routes'
|
|
36
|
+
import {
|
|
37
|
+
createChatStore,
|
|
38
|
+
type ChatDatabase,
|
|
39
|
+
type ChatStore,
|
|
40
|
+
} from '@tangle-network/agent-app/chat-store'
|
|
41
|
+
import { guardResolution } from '@tangle-network/agent-app/platform'
|
|
42
|
+
import {
|
|
43
|
+
createD1TurnEventStore,
|
|
44
|
+
type TurnEventStore,
|
|
45
|
+
} from '@tangle-network/agent-app/stream'
|
|
46
|
+
import { drizzle } from 'drizzle-orm/d1'
|
|
47
|
+
import { accounts, messages, sessions, threads, users, verifications } from './db/schema'
|
|
48
|
+
import type { AppEnv } from './env'
|
|
49
|
+
import { appSlug, createSandboxProduce, resolveSidecarConnection, resolveUploadSink } from './sandbox'
|
|
50
|
+
|
|
51
|
+
export interface ChatAppOverrides {
|
|
52
|
+
/** Test seam: an in-memory drizzle db (the e2e test runs the REAL
|
|
53
|
+
* `migrations/0001_init.sql` into better-sqlite3). Default: `drizzle(env.DB)`. */
|
|
54
|
+
db?: ChatDatabase
|
|
55
|
+
/** Test seam: `createMemoryTurnEventStore()`. Default: D1-backed buffer. */
|
|
56
|
+
turnStore?: TurnEventStore
|
|
57
|
+
/** Test seam: a fake sandbox producer. Default: the real sandbox lane. */
|
|
58
|
+
produce?: (args: ChatTurnProduceArgs<void>) => ChatTurnRouteProducer | Promise<ChatTurnRouteProducer>
|
|
59
|
+
/** Test seam: where large uploads land. Default: the workspace box's fs. */
|
|
60
|
+
uploadSink?: (scope: { workspaceId: string; userId: string }) => Promise<SandboxUploadSink | null>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ChatApp {
|
|
64
|
+
auth: AppAuth
|
|
65
|
+
store: ChatStore
|
|
66
|
+
routes: ChatTurnRoutes & {
|
|
67
|
+
/** POST `{ title?, firstMessage? }` → `{ thread }`. */
|
|
68
|
+
createThread(request: Request): Promise<Response>
|
|
69
|
+
/** GET → `{ threads, total, limit, offset }`. */
|
|
70
|
+
listThreads(request: Request): Promise<Response>
|
|
71
|
+
/** GET → `{ thread, messages }` — the full typed transcript. */
|
|
72
|
+
threadMessages(request: Request, params: { threadId: string }): Promise<Response>
|
|
73
|
+
}
|
|
74
|
+
upload(request: Request): Promise<Response>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const notFound = () => Response.json({ error: 'Thread not found' }, { status: 404 })
|
|
78
|
+
|
|
79
|
+
export function buildChatApp(env: AppEnv, overrides: ChatAppOverrides = {}): ChatApp {
|
|
80
|
+
const db = overrides.db ?? (drizzle(env.DB) as unknown as ChatDatabase)
|
|
81
|
+
const store = createChatStore(db, { threads, messages })
|
|
82
|
+
|
|
83
|
+
const auth = createAppAuth({
|
|
84
|
+
appName: config.name,
|
|
85
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
86
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
87
|
+
db,
|
|
88
|
+
schema: { users, sessions, accounts, verifications },
|
|
89
|
+
// For a throwaway prototype you can swap the drizzle pair for
|
|
90
|
+
// `database: memoryAdapter({ user: [], session: [], account: [], verification: [] })`
|
|
91
|
+
// (from 'better-auth/adapters/memory') — nothing survives a restart.
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/** Session → identity + thread access, for both routes and seams. Guards
|
|
95
|
+
* throw JSON Responses; `guardResolution` adapts them to `{ ok, response }`. */
|
|
96
|
+
async function requireUser(request: Request) {
|
|
97
|
+
return guardResolution(() => auth.requireApiUser(request))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The one product-supplied access step for turn + replay. Identity comes
|
|
101
|
+
* from the SESSION, never from the request body — a client cannot forge
|
|
102
|
+
* `userId`/`workspaceId`. */
|
|
103
|
+
async function authorize(args: {
|
|
104
|
+
request: Request
|
|
105
|
+
intent: 'turn' | 'replay' | 'running'
|
|
106
|
+
body?: Record<string, unknown>
|
|
107
|
+
threadId?: string
|
|
108
|
+
}): Promise<ChatTurnAuthorization<void>> {
|
|
109
|
+
const session = await requireUser(args.request)
|
|
110
|
+
if (!session.ok) return session
|
|
111
|
+
const { user } = session.value
|
|
112
|
+
// `turn` and `running` are thread-scoped and MUST verify ownership: both name
|
|
113
|
+
// a thread the caller supplies (POST body for turn, `?threadId=` for running,
|
|
114
|
+
// whose response enumerates that thread's live turn ids). Inaccessible reads
|
|
115
|
+
// are indistinguishable from missing ones — a cross-workspace probe must not
|
|
116
|
+
// learn the thread exists.
|
|
117
|
+
if (args.intent === 'turn' || args.intent === 'running') {
|
|
118
|
+
const threadId = args.intent === 'turn' ? String(args.body?.threadId ?? '') : args.threadId
|
|
119
|
+
const thread = threadId ? await store.getThread(threadId) : null
|
|
120
|
+
if (!thread || thread.workspaceId !== user.id) return { ok: false, response: notFound() }
|
|
121
|
+
}
|
|
122
|
+
// Replay authorizes on session only: turn ids are unguessable UUIDs minted
|
|
123
|
+
// server-side and announced only on the owner's live stream.
|
|
124
|
+
return { ok: true, tenantId: user.id, userId: user.id, context: undefined }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const routes = createChatTurnRoutes<void>({
|
|
128
|
+
projectId: appSlug,
|
|
129
|
+
authorize,
|
|
130
|
+
store,
|
|
131
|
+
turnStore: overrides.turnStore ?? createD1TurnEventStore(env.DB),
|
|
132
|
+
produce: overrides.produce ?? createSandboxProduce(env),
|
|
133
|
+
interactions: {
|
|
134
|
+
resolveConnection: async ({ request, intent, body }) => {
|
|
135
|
+
const session = await requireUser(request)
|
|
136
|
+
if (!session.ok) return session
|
|
137
|
+
const { user } = session.value
|
|
138
|
+
const threadId =
|
|
139
|
+
intent === 'answer'
|
|
140
|
+
? String(body?.threadId ?? '')
|
|
141
|
+
: (new URL(request.url).searchParams.get('threadId') ?? '')
|
|
142
|
+
const thread = await store.getThread(threadId)
|
|
143
|
+
if (!thread || thread.workspaceId !== user.id) return { ok: false, response: notFound() }
|
|
144
|
+
const connection = await resolveSidecarConnection(env, {
|
|
145
|
+
workspaceId: user.id,
|
|
146
|
+
userId: user.id,
|
|
147
|
+
threadId,
|
|
148
|
+
})
|
|
149
|
+
if (!connection) return { ok: false, unavailable: 'SANDBOX_UNAVAILABLE' }
|
|
150
|
+
return { ok: true, connection }
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const upload = createUploadRoute({
|
|
156
|
+
authorize: async ({ request }) => {
|
|
157
|
+
const session = await requireUser(request)
|
|
158
|
+
if (!session.ok) return session
|
|
159
|
+
const { user } = session.value
|
|
160
|
+
const resolveSink = overrides.uploadSink ?? ((scope) => resolveUploadSink(env, scope))
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
sink: await resolveSink({ workspaceId: user.id, userId: user.id }),
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
async function createThread(request: Request): Promise<Response> {
|
|
169
|
+
const session = await requireUser(request)
|
|
170
|
+
if (!session.ok) return session.response
|
|
171
|
+
const body = (await request.json().catch(() => null)) as
|
|
172
|
+
| { title?: string; firstMessage?: string }
|
|
173
|
+
| null
|
|
174
|
+
const thread = await store.createThread({
|
|
175
|
+
workspaceId: session.value.user.id,
|
|
176
|
+
...(body?.title ? { title: body.title } : {}),
|
|
177
|
+
...(body?.firstMessage ? { firstMessage: body.firstMessage } : {}),
|
|
178
|
+
})
|
|
179
|
+
return Response.json({ thread })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function listThreads(request: Request): Promise<Response> {
|
|
183
|
+
const session = await requireUser(request)
|
|
184
|
+
if (!session.ok) return session.response
|
|
185
|
+
const url = new URL(request.url)
|
|
186
|
+
const limit = Number(url.searchParams.get('limit')) || undefined
|
|
187
|
+
const offset = Number(url.searchParams.get('offset')) || undefined
|
|
188
|
+
const result = await store.listThreads({
|
|
189
|
+
workspaceId: session.value.user.id,
|
|
190
|
+
...(limit !== undefined ? { limit } : {}),
|
|
191
|
+
...(offset !== undefined ? { offset } : {}),
|
|
192
|
+
})
|
|
193
|
+
return Response.json(result)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function threadMessages(request: Request, params: { threadId: string }): Promise<Response> {
|
|
197
|
+
const session = await requireUser(request)
|
|
198
|
+
if (!session.ok) return session.response
|
|
199
|
+
const thread = await store.getThread(params.threadId)
|
|
200
|
+
if (!thread || thread.workspaceId !== session.value.user.id) return notFound()
|
|
201
|
+
return Response.json({ thread, messages: await store.listMessages(params.threadId) })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
auth,
|
|
206
|
+
store,
|
|
207
|
+
routes: { ...routes, createThread, listThreads, threadMessages },
|
|
208
|
+
upload,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/db/schema.ts — the whole database graph, one drizzle schema.
|
|
3
|
+
*
|
|
4
|
+
* Two halves:
|
|
5
|
+
* - better-auth's users/sessions/accounts/verifications tables (the standard
|
|
6
|
+
* shape its drizzle adapter expects — column names must match
|
|
7
|
+
* `migrations/0001_init.sql`, which the e2e test executes for real).
|
|
8
|
+
* - the chat thread/message pair from `createChatTables()` (the shell owns
|
|
9
|
+
* the columns; you never hand-roll them).
|
|
10
|
+
*
|
|
11
|
+
* `workspace_id` on threads is a plain text column here: this template ships
|
|
12
|
+
* single-user workspaces (workspace = user id). Adopting real teams later
|
|
13
|
+
* means passing your workspace table as `createChatTables({ workspaceTable })`
|
|
14
|
+
* — see `@tangle-network/agent-app/teams`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createChatTables } from '@tangle-network/agent-app/chat-store'
|
|
18
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
19
|
+
|
|
20
|
+
// ── better-auth tables ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export const users = sqliteTable('users', {
|
|
23
|
+
id: text('id').primaryKey(),
|
|
24
|
+
name: text('name').notNull(),
|
|
25
|
+
email: text('email').notNull().unique(),
|
|
26
|
+
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(),
|
|
27
|
+
image: text('image'),
|
|
28
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
29
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const sessions = sqliteTable('sessions', {
|
|
33
|
+
id: text('id').primaryKey(),
|
|
34
|
+
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
|
35
|
+
token: text('token').notNull().unique(),
|
|
36
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
37
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
38
|
+
ipAddress: text('ip_address'),
|
|
39
|
+
userAgent: text('user_agent'),
|
|
40
|
+
userId: text('user_id').notNull(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export const accounts = sqliteTable('accounts', {
|
|
44
|
+
id: text('id').primaryKey(),
|
|
45
|
+
accountId: text('account_id').notNull(),
|
|
46
|
+
providerId: text('provider_id').notNull(),
|
|
47
|
+
userId: text('user_id').notNull(),
|
|
48
|
+
accessToken: text('access_token'),
|
|
49
|
+
refreshToken: text('refresh_token'),
|
|
50
|
+
idToken: text('id_token'),
|
|
51
|
+
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
|
|
52
|
+
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
|
|
53
|
+
scope: text('scope'),
|
|
54
|
+
password: text('password'),
|
|
55
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
56
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export const verifications = sqliteTable('verifications', {
|
|
60
|
+
id: text('id').primaryKey(),
|
|
61
|
+
identifier: text('identifier').notNull(),
|
|
62
|
+
value: text('value').notNull(),
|
|
63
|
+
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
|
64
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
65
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ── chat tables (shell-owned columns) ───────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export const { threads, messages } = createChatTables()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/env.ts — the Cloudflare bindings + vars this worker reads.
|
|
3
|
+
*
|
|
4
|
+
* Locally these come from wrangler.toml `[vars]` + `.dev.vars` (secrets); in
|
|
5
|
+
* production from the dashboard / `wrangler secret put`. See
|
|
6
|
+
* `.dev.vars.example` for the full list with comments.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface AppEnv {
|
|
10
|
+
/** D1 database — run `migrations/` against it before first boot. */
|
|
11
|
+
DB: D1Database
|
|
12
|
+
|
|
13
|
+
/** Absolute origin better-auth serves from (e.g. http://localhost:8787). */
|
|
14
|
+
BETTER_AUTH_URL: string
|
|
15
|
+
/** better-auth HMAC secret (secret; set in .dev.vars / `wrangler secret`). */
|
|
16
|
+
BETTER_AUTH_SECRET: string
|
|
17
|
+
|
|
18
|
+
/** Overrides `config.model.default` without a redeploy. */
|
|
19
|
+
MODEL_NAME?: string
|
|
20
|
+
/** Tangle Router key the harness bills model calls against. */
|
|
21
|
+
TANGLE_API_KEY?: string
|
|
22
|
+
/** Tangle Router base URL; omit for the platform default. */
|
|
23
|
+
TANGLE_ROUTER_URL?: string
|
|
24
|
+
|
|
25
|
+
/** Sandbox gateway credentials. Without them every turn fails loud with a
|
|
26
|
+
* clear error — there is no mock fallback. */
|
|
27
|
+
SANDBOX_API_KEY?: string
|
|
28
|
+
SANDBOX_GATEWAY_URL?: string
|
|
29
|
+
|
|
30
|
+
// Optional R2 bucket for product artifacts — OFF by default. Uncomment the
|
|
31
|
+
// `[[r2_buckets]]` block in wrangler.toml and this binding together.
|
|
32
|
+
// ARTIFACTS: R2Bucket
|
|
33
|
+
}
|