weifuwu 0.33.2 → 0.33.3

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.
@@ -2,10 +2,13 @@
2
2
  * weifuwu/client 应用 — 创建 ctx + 中间件链 + 挂载组件
3
3
  *
4
4
  * ```tsx
5
- * import { createApp, router } from 'weifuwu/client'
5
+ * import { createApp, api, auth, ws, router } from 'weifuwu/client'
6
6
  *
7
7
  * const app = createApp()
8
- * app.use(router({ routes: [...] }))
8
+ * app.use(api())
9
+ * app.use(auth())
10
+ * app.use(ws())
11
+ * app.use(router({ routes }))
9
12
  * app.mount('#root', AppShell)
10
13
  * ```
11
14
  */
@@ -17,5 +20,5 @@ import type { Component } from './jsx-runtime.ts';
17
20
  export declare function createApp(): {
18
21
  ctx: WfuiContext;
19
22
  use: (mw: AppMiddleware) => any;
20
- mount: (rootSelector: string, RootComponent: Component) => void;
23
+ mount: (rootSelector: string, RootComponent: Component) => Promise<void>;
21
24
  };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Chat — 消息聊天组件
3
+ *
4
+ * 与 weifuwu 后端 messager + agent 模块对接。
5
+ *
6
+ * ```ts
7
+ * import { Chat } from 'weifuwu/client'
8
+ *
9
+ * function ChatPage(_, ctx) {
10
+ * return Chat({ conversationId: '123' }, ctx)
11
+ * }
12
+ * ```
13
+ */
14
+ import type { WfuiContext } from '../types.ts';
15
+ export declare function Chat({ conversationId }: {
16
+ conversationId: string;
17
+ }, ctx: WfuiContext): Node;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * LoginForm — 登录/注册组件
3
+ *
4
+ * ```ts
5
+ * import { LoginForm } from 'weifuwu/client'
6
+ *
7
+ * function LoginPage(_, ctx) {
8
+ * if (ctx.isAuthenticated) return ctx.app.navigate('/')
9
+ * return LoginForm({}, ctx)
10
+ * }
11
+ * ```
12
+ */
13
+ import type { WfuiContext } from '../types.ts';
14
+ export declare function LoginForm(_props: {}, ctx: WfuiContext): Node;
@@ -2,13 +2,14 @@
2
2
  * weifuwu/client — 响应式前端框架,TSX + Signal
3
3
  *
4
4
  * ```tsx
5
- * import { signal, computed, Show, For, createApp, router, RouteView } from 'weifuwu/client'
5
+ * import { signal, createdApp, api, auth, ws, router, RouteView, Show, For } from 'weifuwu/client'
6
6
  * import type { WfuiContext } from 'weifuwu/client'
7
7
  *
8
8
  * const app = createApp()
9
- * app.use(router({ routes: [
10
- * { path: '/', component: HomePage },
11
- * ]}))
9
+ * app.use(api())
10
+ * app.use(auth())
11
+ * app.use(ws())
12
+ * app.use(router({ routes }))
12
13
  * app.mount('#root', AppShell)
13
14
  * ```
14
15
  */
@@ -19,3 +20,9 @@ export type { Component } from './jsx-runtime.ts';
19
20
  export type { WfuiContext, AppMiddleware, RouteDef } from './types.ts';
20
21
  export { createApp } from './app.ts';
21
22
  export { router, RouteView } from './router.ts';
23
+ export { api, ApiClient, ApiError } from './middleware/api.ts';
24
+ export { auth } from './middleware/auth.ts';
25
+ export type { UserRecord } from './middleware/auth.ts';
26
+ export { ws } from './middleware/ws.ts';
27
+ export { LoginForm } from './components/LoginForm.ts';
28
+ export { Chat } from './components/Chat.ts';
@@ -189,6 +189,17 @@ function createApp() {
189
189
  window.dispatchEvent(new CustomEvent("wefu:navigate", { detail: { path } }));
190
190
  }
191
191
  },
192
+ user: null,
193
+ token: null,
194
+ isAuthenticated: false,
195
+ login: async () => {
196
+ },
197
+ logout: () => {
198
+ },
199
+ register: async () => {
200
+ },
201
+ api: null,
202
+ ws: null,
192
203
  provide(key, value) {
193
204
  provides.set(key, value);
194
205
  },
@@ -204,9 +215,9 @@ function createApp() {
204
215
  middlewares.push(mw);
205
216
  return this;
206
217
  },
207
- mount(rootSelector, RootComponent) {
218
+ async mount(rootSelector, RootComponent) {
208
219
  for (const mw of middlewares) {
209
- ctx = mw(ctx);
220
+ ctx = await mw(ctx);
210
221
  }
211
222
  setCtx(ctx);
212
223
  const app = jsx(RootComponent, {});
@@ -302,11 +313,373 @@ function RouteView(_props, ctx) {
302
313
  window.addEventListener("wefu:route", render);
303
314
  return el;
304
315
  }
316
+
317
+ // src/client/middleware/api.ts
318
+ var _getToken = () => null;
319
+ function setTokenGetter(fn) {
320
+ _getToken = fn;
321
+ }
322
+ function getToken() {
323
+ return _getToken();
324
+ }
325
+ var ApiError = class extends Error {
326
+ status;
327
+ constructor(status, message) {
328
+ super(`[${status}] ${message}`);
329
+ this.name = "ApiError";
330
+ this.status = status;
331
+ }
332
+ };
333
+ var ApiClient = class {
334
+ #baseUrl;
335
+ constructor(baseUrl = "/api") {
336
+ this.#baseUrl = baseUrl;
337
+ }
338
+ async request(method, path, body) {
339
+ const headers = {};
340
+ const token = getToken();
341
+ if (token) headers["Authorization"] = `Bearer ${token}`;
342
+ if (body != null) headers["Content-Type"] = "application/json";
343
+ const res = await fetch(`${this.#baseUrl}${path}`, {
344
+ method,
345
+ headers,
346
+ body: body != null ? JSON.stringify(body) : void 0
347
+ });
348
+ if (!res.ok) {
349
+ throw new ApiError(res.status, await res.text().catch(() => res.statusText));
350
+ }
351
+ if (res.status === 204) return void 0;
352
+ return res.json();
353
+ }
354
+ get(path) {
355
+ return this.request("GET", path);
356
+ }
357
+ post(path, body) {
358
+ return this.request("POST", path, body);
359
+ }
360
+ put(path, body) {
361
+ return this.request("PUT", path, body);
362
+ }
363
+ patch(path, body) {
364
+ return this.request("PATCH", path, body);
365
+ }
366
+ delete(path) {
367
+ return this.request("DELETE", path);
368
+ }
369
+ };
370
+ function api(opts = {}) {
371
+ return (ctx) => {
372
+ ctx.api = new ApiClient(opts.baseUrl ?? "/api");
373
+ return ctx;
374
+ };
375
+ }
376
+
377
+ // src/client/middleware/auth.ts
378
+ function auth(opts = {}) {
379
+ const storageKey = opts.storageKey ?? "wefu:auth";
380
+ const loginPath = opts.loginPath ?? "/api/login";
381
+ const registerPath = opts.registerPath ?? "/api/register";
382
+ const mePath = opts.mePath ?? "/api/me";
383
+ return async (ctx) => {
384
+ const userSignal = signal(null);
385
+ const tokenSignal = signal(null);
386
+ setTokenGetter(() => tokenSignal.value);
387
+ function persist(user, token) {
388
+ userSignal.value = user;
389
+ tokenSignal.value = token;
390
+ if (token && user) {
391
+ localStorage.setItem(storageKey, JSON.stringify({ user, token }));
392
+ } else {
393
+ localStorage.removeItem(storageKey);
394
+ }
395
+ }
396
+ try {
397
+ const saved = localStorage.getItem(storageKey);
398
+ if (saved) {
399
+ const { user, token } = JSON.parse(saved);
400
+ userSignal.value = user;
401
+ tokenSignal.value = token;
402
+ setTokenGetter(() => token);
403
+ }
404
+ } catch {
405
+ }
406
+ if (tokenSignal.value) {
407
+ try {
408
+ const api2 = new ApiClient();
409
+ const user = await api2.get(mePath);
410
+ persist(user, tokenSignal.value);
411
+ } catch {
412
+ persist(null, null);
413
+ }
414
+ }
415
+ return {
416
+ ...ctx,
417
+ get user() {
418
+ return userSignal.value;
419
+ },
420
+ get token() {
421
+ return tokenSignal.value;
422
+ },
423
+ get isAuthenticated() {
424
+ return !!tokenSignal.value && !!userSignal.value;
425
+ },
426
+ async login(email, password) {
427
+ const api2 = new ApiClient();
428
+ const res = await api2.post(loginPath, { email, password });
429
+ persist(res.user, res.token);
430
+ },
431
+ logout() {
432
+ persist(null, null);
433
+ },
434
+ async register(input) {
435
+ const api2 = new ApiClient();
436
+ const res = await api2.post(registerPath, input);
437
+ persist(res.user, res.token);
438
+ }
439
+ };
440
+ };
441
+ }
442
+
443
+ // src/client/middleware/ws.ts
444
+ function ws(opts = {}) {
445
+ const wsUrl = opts.url ?? "/ws";
446
+ const reconnectInterval = opts.reconnectInterval ?? 3e3;
447
+ const maxReconnect = opts.maxReconnect ?? 10;
448
+ return (ctx) => {
449
+ const isConnected = signal(false);
450
+ const messageHandlers = /* @__PURE__ */ new Set();
451
+ let socket = null;
452
+ let reconnectCount = 0;
453
+ let reconnectTimer = null;
454
+ function connect() {
455
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return;
456
+ const token = getToken();
457
+ const url = token ? `${wsUrl}?token=${token}` : wsUrl;
458
+ try {
459
+ socket = new WebSocket(url);
460
+ } catch {
461
+ scheduleReconnect();
462
+ return;
463
+ }
464
+ socket.onopen = () => {
465
+ isConnected.value = true;
466
+ reconnectCount = 0;
467
+ };
468
+ socket.onmessage = (event) => {
469
+ try {
470
+ const data = JSON.parse(event.data);
471
+ for (const h3 of messageHandlers) h3(data);
472
+ } catch {
473
+ for (const h3 of messageHandlers) h3(event.data);
474
+ }
475
+ };
476
+ socket.onclose = () => {
477
+ isConnected.value = false;
478
+ socket = null;
479
+ scheduleReconnect();
480
+ };
481
+ socket.onerror = () => {
482
+ socket?.close();
483
+ };
484
+ }
485
+ function scheduleReconnect() {
486
+ if (reconnectCount >= maxReconnect) return;
487
+ reconnectCount++;
488
+ reconnectTimer = setTimeout(connect, reconnectInterval * reconnectCount);
489
+ }
490
+ function send(data) {
491
+ if (socket?.readyState === WebSocket.OPEN) {
492
+ socket.send(typeof data === "string" ? data : JSON.stringify(data));
493
+ }
494
+ }
495
+ function join(room) {
496
+ send({ type: "join", room });
497
+ }
498
+ function leave(room) {
499
+ send({ type: "leave", room });
500
+ }
501
+ connect();
502
+ return {
503
+ ...ctx,
504
+ ws: {
505
+ send,
506
+ onMessage: (handler) => {
507
+ messageHandlers.add(handler);
508
+ return () => messageHandlers.delete(handler);
509
+ },
510
+ join,
511
+ leave,
512
+ get isConnected() {
513
+ return isConnected;
514
+ }
515
+ }
516
+ };
517
+ };
518
+ }
519
+
520
+ // src/client/components/LoginForm.ts
521
+ var h = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
522
+ function LoginForm(_props, ctx) {
523
+ const mode = signal("login");
524
+ const email = signal("");
525
+ const name = signal("");
526
+ const password = signal("");
527
+ const error = signal(null);
528
+ const loading = signal(false);
529
+ const isRegister = computed(() => mode.value === "register");
530
+ const hasError = computed(() => error.value != null);
531
+ const submit = async () => {
532
+ error.value = null;
533
+ loading.value = true;
534
+ try {
535
+ if (mode.value === "login") {
536
+ await ctx.login(email.value, password.value);
537
+ } else {
538
+ await ctx.register({ email: email.value, name: name.value, password: password.value });
539
+ }
540
+ } catch (e) {
541
+ error.value = e.message ?? "\u64CD\u4F5C\u5931\u8D25";
542
+ } finally {
543
+ loading.value = false;
544
+ }
545
+ };
546
+ return h(
547
+ "div",
548
+ { class: "wefu-login" },
549
+ h(
550
+ "div",
551
+ { class: "wefu-login-card" },
552
+ h("h2", null, mode.value === "login" ? "\u767B\u5F55" : "\u6CE8\u518C"),
553
+ h(
554
+ "div",
555
+ { class: "wefu-login-tabs" },
556
+ h("button", { class: mode.value === "login" ? "active" : "", onClick: () => mode.value = "login" }, "\u767B\u5F55"),
557
+ h("button", { class: mode.value === "register" ? "active" : "", onClick: () => mode.value = "register" }, "\u6CE8\u518C")
558
+ ),
559
+ h(
560
+ "form",
561
+ { onSubmit: (e) => {
562
+ e.preventDefault();
563
+ submit();
564
+ } },
565
+ Show({
566
+ when: isRegister,
567
+ children: h(
568
+ "div",
569
+ { class: "wefu-field" },
570
+ h("label", null, "\u6635\u79F0"),
571
+ h("input", { value: name, onInput: (e) => name.value = e.target.value, placeholder: "\u4F60\u7684\u6635\u79F0" })
572
+ )
573
+ }),
574
+ h(
575
+ "div",
576
+ { class: "wefu-field" },
577
+ h("label", null, "\u90AE\u7BB1"),
578
+ h("input", { type: "email", value: email, onInput: (e) => email.value = e.target.value, placeholder: "\u90AE\u7BB1" })
579
+ ),
580
+ h(
581
+ "div",
582
+ { class: "wefu-field" },
583
+ h("label", null, "\u5BC6\u7801"),
584
+ h("input", { type: "password", value: password, onInput: (e) => password.value = e.target.value, placeholder: "\u5BC6\u7801" })
585
+ ),
586
+ Show({
587
+ when: hasError,
588
+ children: h("div", { class: "wefu-error" }, error)
589
+ }),
590
+ h(
591
+ "button",
592
+ { type: "submit", class: "wefu-btn wefu-btn-primary", disabled: loading },
593
+ loading ? "\u5904\u7406\u4E2D..." : mode.value === "login" ? "\u767B\u5F55" : "\u6CE8\u518C"
594
+ )
595
+ )
596
+ )
597
+ );
598
+ }
599
+
600
+ // src/client/components/Chat.ts
601
+ var h2 = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
602
+ function Chat({ conversationId }, ctx) {
603
+ const messages = signal([]);
604
+ const input = signal("");
605
+ const loading = signal(true);
606
+ const isEmpty = signal(false);
607
+ ctx.api.get(`/api/conversations/${conversationId}/messages`).then((msgs) => {
608
+ messages.value = Array.isArray(msgs) ? msgs.reverse() : [];
609
+ loading.value = false;
610
+ isEmpty.value = messages.value.length === 0;
611
+ }).catch(() => {
612
+ loading.value = false;
613
+ isEmpty.value = true;
614
+ });
615
+ const unsub = ctx.ws.onMessage((data) => {
616
+ if (data.conversation_id === conversationId) {
617
+ messages.value = [...messages.value, data];
618
+ isEmpty.value = false;
619
+ }
620
+ });
621
+ const send = () => {
622
+ const text = input.value.trim();
623
+ if (!text) return;
624
+ input.value = "";
625
+ ctx.api.post("/api/messages", { conversationId, body: text }).catch(() => {
626
+ });
627
+ };
628
+ return h2(
629
+ "div",
630
+ { class: "wefu-chat" },
631
+ // 消息列表
632
+ h2(
633
+ "div",
634
+ { class: "wefu-chat-messages" },
635
+ Show({ when: loading, children: h2("div", { class: "wefu-chat-loading" }, "\u52A0\u8F7D\u4E2D...") }),
636
+ Show({ when: isEmpty, children: h2("div", { class: "wefu-chat-empty" }, "\u6682\u65E0\u6D88\u606F") }),
637
+ h2(
638
+ "div",
639
+ { class: "wefu-chat-list" },
640
+ For({
641
+ each: messages.value,
642
+ children: (msg) => h2(
643
+ "div",
644
+ { class: `wefu-chat-msg ${msg.sender_id === ctx.user?.id ? "mine" : ""}` },
645
+ h2(
646
+ "div",
647
+ { class: "wefu-chat-msg-header" },
648
+ h2("span", { class: "wefu-chat-msg-sender" }, msg.sender_name ?? ""),
649
+ h2("span", { class: "wefu-chat-msg-time" }, new Date(msg.created_at).toLocaleTimeString())
650
+ ),
651
+ h2("div", { class: "wefu-chat-msg-body" }, msg.body)
652
+ )
653
+ })
654
+ )
655
+ ),
656
+ // 输入区
657
+ h2(
658
+ "div",
659
+ { class: "wefu-chat-input" },
660
+ h2("input", {
661
+ value: input,
662
+ onInput: (e) => input.value = e.target.value,
663
+ onKeyDown: (e) => {
664
+ if (e.key === "Enter") send();
665
+ },
666
+ placeholder: "\u8F93\u5165\u6D88\u606F..."
667
+ }),
668
+ h2("button", { class: "wefu-btn", onClick: send }, "\u53D1\u9001")
669
+ )
670
+ );
671
+ }
305
672
  export {
673
+ ApiClient,
674
+ ApiError,
675
+ Chat,
306
676
  For,
307
677
  Fragment,
678
+ LoginForm,
308
679
  RouteView,
309
680
  Show,
681
+ api,
682
+ auth,
310
683
  computed,
311
684
  createApp,
312
685
  domMount,
@@ -316,5 +689,6 @@ export {
316
689
  jsxDEV,
317
690
  jsxs,
318
691
  router,
319
- signal
692
+ signal,
693
+ ws
320
694
  };
@@ -189,6 +189,17 @@ function createApp() {
189
189
  window.dispatchEvent(new CustomEvent("wefu:navigate", { detail: { path } }));
190
190
  }
191
191
  },
192
+ user: null,
193
+ token: null,
194
+ isAuthenticated: false,
195
+ login: async () => {
196
+ },
197
+ logout: () => {
198
+ },
199
+ register: async () => {
200
+ },
201
+ api: null,
202
+ ws: null,
192
203
  provide(key, value) {
193
204
  provides.set(key, value);
194
205
  },
@@ -204,9 +215,9 @@ function createApp() {
204
215
  middlewares.push(mw);
205
216
  return this;
206
217
  },
207
- mount(rootSelector, RootComponent) {
218
+ async mount(rootSelector, RootComponent) {
208
219
  for (const mw of middlewares) {
209
- ctx = mw(ctx);
220
+ ctx = await mw(ctx);
210
221
  }
211
222
  setCtx(ctx);
212
223
  const app = jsx(RootComponent, {});
@@ -302,11 +313,373 @@ function RouteView(_props, ctx) {
302
313
  window.addEventListener("wefu:route", render);
303
314
  return el;
304
315
  }
316
+
317
+ // src/client/middleware/api.ts
318
+ var _getToken = () => null;
319
+ function setTokenGetter(fn) {
320
+ _getToken = fn;
321
+ }
322
+ function getToken() {
323
+ return _getToken();
324
+ }
325
+ var ApiError = class extends Error {
326
+ status;
327
+ constructor(status, message) {
328
+ super(`[${status}] ${message}`);
329
+ this.name = "ApiError";
330
+ this.status = status;
331
+ }
332
+ };
333
+ var ApiClient = class {
334
+ #baseUrl;
335
+ constructor(baseUrl = "/api") {
336
+ this.#baseUrl = baseUrl;
337
+ }
338
+ async request(method, path, body) {
339
+ const headers = {};
340
+ const token = getToken();
341
+ if (token) headers["Authorization"] = `Bearer ${token}`;
342
+ if (body != null) headers["Content-Type"] = "application/json";
343
+ const res = await fetch(`${this.#baseUrl}${path}`, {
344
+ method,
345
+ headers,
346
+ body: body != null ? JSON.stringify(body) : void 0
347
+ });
348
+ if (!res.ok) {
349
+ throw new ApiError(res.status, await res.text().catch(() => res.statusText));
350
+ }
351
+ if (res.status === 204) return void 0;
352
+ return res.json();
353
+ }
354
+ get(path) {
355
+ return this.request("GET", path);
356
+ }
357
+ post(path, body) {
358
+ return this.request("POST", path, body);
359
+ }
360
+ put(path, body) {
361
+ return this.request("PUT", path, body);
362
+ }
363
+ patch(path, body) {
364
+ return this.request("PATCH", path, body);
365
+ }
366
+ delete(path) {
367
+ return this.request("DELETE", path);
368
+ }
369
+ };
370
+ function api(opts = {}) {
371
+ return (ctx) => {
372
+ ctx.api = new ApiClient(opts.baseUrl ?? "/api");
373
+ return ctx;
374
+ };
375
+ }
376
+
377
+ // src/client/middleware/auth.ts
378
+ function auth(opts = {}) {
379
+ const storageKey = opts.storageKey ?? "wefu:auth";
380
+ const loginPath = opts.loginPath ?? "/api/login";
381
+ const registerPath = opts.registerPath ?? "/api/register";
382
+ const mePath = opts.mePath ?? "/api/me";
383
+ return async (ctx) => {
384
+ const userSignal = signal(null);
385
+ const tokenSignal = signal(null);
386
+ setTokenGetter(() => tokenSignal.value);
387
+ function persist(user, token) {
388
+ userSignal.value = user;
389
+ tokenSignal.value = token;
390
+ if (token && user) {
391
+ localStorage.setItem(storageKey, JSON.stringify({ user, token }));
392
+ } else {
393
+ localStorage.removeItem(storageKey);
394
+ }
395
+ }
396
+ try {
397
+ const saved = localStorage.getItem(storageKey);
398
+ if (saved) {
399
+ const { user, token } = JSON.parse(saved);
400
+ userSignal.value = user;
401
+ tokenSignal.value = token;
402
+ setTokenGetter(() => token);
403
+ }
404
+ } catch {
405
+ }
406
+ if (tokenSignal.value) {
407
+ try {
408
+ const api2 = new ApiClient();
409
+ const user = await api2.get(mePath);
410
+ persist(user, tokenSignal.value);
411
+ } catch {
412
+ persist(null, null);
413
+ }
414
+ }
415
+ return {
416
+ ...ctx,
417
+ get user() {
418
+ return userSignal.value;
419
+ },
420
+ get token() {
421
+ return tokenSignal.value;
422
+ },
423
+ get isAuthenticated() {
424
+ return !!tokenSignal.value && !!userSignal.value;
425
+ },
426
+ async login(email, password) {
427
+ const api2 = new ApiClient();
428
+ const res = await api2.post(loginPath, { email, password });
429
+ persist(res.user, res.token);
430
+ },
431
+ logout() {
432
+ persist(null, null);
433
+ },
434
+ async register(input) {
435
+ const api2 = new ApiClient();
436
+ const res = await api2.post(registerPath, input);
437
+ persist(res.user, res.token);
438
+ }
439
+ };
440
+ };
441
+ }
442
+
443
+ // src/client/middleware/ws.ts
444
+ function ws(opts = {}) {
445
+ const wsUrl = opts.url ?? "/ws";
446
+ const reconnectInterval = opts.reconnectInterval ?? 3e3;
447
+ const maxReconnect = opts.maxReconnect ?? 10;
448
+ return (ctx) => {
449
+ const isConnected = signal(false);
450
+ const messageHandlers = /* @__PURE__ */ new Set();
451
+ let socket = null;
452
+ let reconnectCount = 0;
453
+ let reconnectTimer = null;
454
+ function connect() {
455
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return;
456
+ const token = getToken();
457
+ const url = token ? `${wsUrl}?token=${token}` : wsUrl;
458
+ try {
459
+ socket = new WebSocket(url);
460
+ } catch {
461
+ scheduleReconnect();
462
+ return;
463
+ }
464
+ socket.onopen = () => {
465
+ isConnected.value = true;
466
+ reconnectCount = 0;
467
+ };
468
+ socket.onmessage = (event) => {
469
+ try {
470
+ const data = JSON.parse(event.data);
471
+ for (const h3 of messageHandlers) h3(data);
472
+ } catch {
473
+ for (const h3 of messageHandlers) h3(event.data);
474
+ }
475
+ };
476
+ socket.onclose = () => {
477
+ isConnected.value = false;
478
+ socket = null;
479
+ scheduleReconnect();
480
+ };
481
+ socket.onerror = () => {
482
+ socket?.close();
483
+ };
484
+ }
485
+ function scheduleReconnect() {
486
+ if (reconnectCount >= maxReconnect) return;
487
+ reconnectCount++;
488
+ reconnectTimer = setTimeout(connect, reconnectInterval * reconnectCount);
489
+ }
490
+ function send(data) {
491
+ if (socket?.readyState === WebSocket.OPEN) {
492
+ socket.send(typeof data === "string" ? data : JSON.stringify(data));
493
+ }
494
+ }
495
+ function join(room) {
496
+ send({ type: "join", room });
497
+ }
498
+ function leave(room) {
499
+ send({ type: "leave", room });
500
+ }
501
+ connect();
502
+ return {
503
+ ...ctx,
504
+ ws: {
505
+ send,
506
+ onMessage: (handler) => {
507
+ messageHandlers.add(handler);
508
+ return () => messageHandlers.delete(handler);
509
+ },
510
+ join,
511
+ leave,
512
+ get isConnected() {
513
+ return isConnected;
514
+ }
515
+ }
516
+ };
517
+ };
518
+ }
519
+
520
+ // src/client/components/LoginForm.ts
521
+ var h = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
522
+ function LoginForm(_props, ctx) {
523
+ const mode = signal("login");
524
+ const email = signal("");
525
+ const name = signal("");
526
+ const password = signal("");
527
+ const error = signal(null);
528
+ const loading = signal(false);
529
+ const isRegister = computed(() => mode.value === "register");
530
+ const hasError = computed(() => error.value != null);
531
+ const submit = async () => {
532
+ error.value = null;
533
+ loading.value = true;
534
+ try {
535
+ if (mode.value === "login") {
536
+ await ctx.login(email.value, password.value);
537
+ } else {
538
+ await ctx.register({ email: email.value, name: name.value, password: password.value });
539
+ }
540
+ } catch (e) {
541
+ error.value = e.message ?? "\u64CD\u4F5C\u5931\u8D25";
542
+ } finally {
543
+ loading.value = false;
544
+ }
545
+ };
546
+ return h(
547
+ "div",
548
+ { class: "wefu-login" },
549
+ h(
550
+ "div",
551
+ { class: "wefu-login-card" },
552
+ h("h2", null, mode.value === "login" ? "\u767B\u5F55" : "\u6CE8\u518C"),
553
+ h(
554
+ "div",
555
+ { class: "wefu-login-tabs" },
556
+ h("button", { class: mode.value === "login" ? "active" : "", onClick: () => mode.value = "login" }, "\u767B\u5F55"),
557
+ h("button", { class: mode.value === "register" ? "active" : "", onClick: () => mode.value = "register" }, "\u6CE8\u518C")
558
+ ),
559
+ h(
560
+ "form",
561
+ { onSubmit: (e) => {
562
+ e.preventDefault();
563
+ submit();
564
+ } },
565
+ Show({
566
+ when: isRegister,
567
+ children: h(
568
+ "div",
569
+ { class: "wefu-field" },
570
+ h("label", null, "\u6635\u79F0"),
571
+ h("input", { value: name, onInput: (e) => name.value = e.target.value, placeholder: "\u4F60\u7684\u6635\u79F0" })
572
+ )
573
+ }),
574
+ h(
575
+ "div",
576
+ { class: "wefu-field" },
577
+ h("label", null, "\u90AE\u7BB1"),
578
+ h("input", { type: "email", value: email, onInput: (e) => email.value = e.target.value, placeholder: "\u90AE\u7BB1" })
579
+ ),
580
+ h(
581
+ "div",
582
+ { class: "wefu-field" },
583
+ h("label", null, "\u5BC6\u7801"),
584
+ h("input", { type: "password", value: password, onInput: (e) => password.value = e.target.value, placeholder: "\u5BC6\u7801" })
585
+ ),
586
+ Show({
587
+ when: hasError,
588
+ children: h("div", { class: "wefu-error" }, error)
589
+ }),
590
+ h(
591
+ "button",
592
+ { type: "submit", class: "wefu-btn wefu-btn-primary", disabled: loading },
593
+ loading ? "\u5904\u7406\u4E2D..." : mode.value === "login" ? "\u767B\u5F55" : "\u6CE8\u518C"
594
+ )
595
+ )
596
+ )
597
+ );
598
+ }
599
+
600
+ // src/client/components/Chat.ts
601
+ var h2 = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
602
+ function Chat({ conversationId }, ctx) {
603
+ const messages = signal([]);
604
+ const input = signal("");
605
+ const loading = signal(true);
606
+ const isEmpty = signal(false);
607
+ ctx.api.get(`/api/conversations/${conversationId}/messages`).then((msgs) => {
608
+ messages.value = Array.isArray(msgs) ? msgs.reverse() : [];
609
+ loading.value = false;
610
+ isEmpty.value = messages.value.length === 0;
611
+ }).catch(() => {
612
+ loading.value = false;
613
+ isEmpty.value = true;
614
+ });
615
+ const unsub = ctx.ws.onMessage((data) => {
616
+ if (data.conversation_id === conversationId) {
617
+ messages.value = [...messages.value, data];
618
+ isEmpty.value = false;
619
+ }
620
+ });
621
+ const send = () => {
622
+ const text = input.value.trim();
623
+ if (!text) return;
624
+ input.value = "";
625
+ ctx.api.post("/api/messages", { conversationId, body: text }).catch(() => {
626
+ });
627
+ };
628
+ return h2(
629
+ "div",
630
+ { class: "wefu-chat" },
631
+ // 消息列表
632
+ h2(
633
+ "div",
634
+ { class: "wefu-chat-messages" },
635
+ Show({ when: loading, children: h2("div", { class: "wefu-chat-loading" }, "\u52A0\u8F7D\u4E2D...") }),
636
+ Show({ when: isEmpty, children: h2("div", { class: "wefu-chat-empty" }, "\u6682\u65E0\u6D88\u606F") }),
637
+ h2(
638
+ "div",
639
+ { class: "wefu-chat-list" },
640
+ For({
641
+ each: messages.value,
642
+ children: (msg) => h2(
643
+ "div",
644
+ { class: `wefu-chat-msg ${msg.sender_id === ctx.user?.id ? "mine" : ""}` },
645
+ h2(
646
+ "div",
647
+ { class: "wefu-chat-msg-header" },
648
+ h2("span", { class: "wefu-chat-msg-sender" }, msg.sender_name ?? ""),
649
+ h2("span", { class: "wefu-chat-msg-time" }, new Date(msg.created_at).toLocaleTimeString())
650
+ ),
651
+ h2("div", { class: "wefu-chat-msg-body" }, msg.body)
652
+ )
653
+ })
654
+ )
655
+ ),
656
+ // 输入区
657
+ h2(
658
+ "div",
659
+ { class: "wefu-chat-input" },
660
+ h2("input", {
661
+ value: input,
662
+ onInput: (e) => input.value = e.target.value,
663
+ onKeyDown: (e) => {
664
+ if (e.key === "Enter") send();
665
+ },
666
+ placeholder: "\u8F93\u5165\u6D88\u606F..."
667
+ }),
668
+ h2("button", { class: "wefu-btn", onClick: send }, "\u53D1\u9001")
669
+ )
670
+ );
671
+ }
305
672
  export {
673
+ ApiClient,
674
+ ApiError,
675
+ Chat,
306
676
  For,
307
677
  Fragment,
678
+ LoginForm,
308
679
  RouteView,
309
680
  Show,
681
+ api,
682
+ auth,
310
683
  computed,
311
684
  createApp,
312
685
  domMount,
@@ -316,5 +689,6 @@ export {
316
689
  jsxDEV,
317
690
  jsxs,
318
691
  router,
319
- signal
692
+ signal,
693
+ ws
320
694
  };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * api middleware — 注入 ctx.api
3
+ *
4
+ * ```tsx
5
+ * app.use(api({ baseUrl: '/api' }))
6
+ * app.use(auth({ api: ctx.api }))
7
+ *
8
+ * // In component:
9
+ * const users = await ctx.api.get('/api/users')
10
+ * const msg = await ctx.api.post('/api/messages', { body: 'hello' })
11
+ * ```
12
+ */
13
+ import type { AppMiddleware } from '../types.ts';
14
+ export declare function setTokenGetter(fn: () => string | null): void;
15
+ export declare function getToken(): string | null;
16
+ export declare class ApiError extends Error {
17
+ status: number;
18
+ constructor(status: number, message: string);
19
+ }
20
+ export declare class ApiClient {
21
+ #private;
22
+ constructor(baseUrl?: string);
23
+ request<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
24
+ get<T = unknown>(path: string): Promise<T>;
25
+ post<T = unknown>(path: string, body?: unknown): Promise<T>;
26
+ put<T = unknown>(path: string, body?: unknown): Promise<T>;
27
+ patch<T = unknown>(path: string, body?: unknown): Promise<T>;
28
+ delete<T = unknown>(path: string): Promise<T>;
29
+ }
30
+ export declare function api(opts?: {
31
+ baseUrl?: string;
32
+ }): AppMiddleware;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * auth middleware — 注入 ctx.user / ctx.login / ctx.logout / ctx.register
3
+ *
4
+ * ```tsx
5
+ * app.use(api())
6
+ * app.use(auth())
7
+ *
8
+ * // In component:
9
+ * if (!ctx.user) return <LoginPage />
10
+ * ctx.login(email, password)
11
+ * ctx.logout()
12
+ * ```
13
+ */
14
+ import type { AppMiddleware } from '../types.ts';
15
+ export interface UserRecord {
16
+ id: string;
17
+ email: string;
18
+ name: string;
19
+ role: string;
20
+ avatar?: string;
21
+ }
22
+ export interface AuthOptions {
23
+ /** localStorage key,默认 'wefu:auth' */
24
+ storageKey?: string;
25
+ /** 登录 API 路径,默认 '/api/login' */
26
+ loginPath?: string;
27
+ /** 注册 API 路径,默认 '/api/register' */
28
+ registerPath?: string;
29
+ /** 获取当前用户 API 路径,默认 '/api/me' */
30
+ mePath?: string;
31
+ }
32
+ export declare function auth(opts?: AuthOptions): AppMiddleware;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * ws middleware — 注入 ctx.ws
3
+ *
4
+ * WebSocket 客户端,自动重连,支持房间。
5
+ *
6
+ * ```tsx
7
+ * app.use(ws({ url: '/ws' }))
8
+ *
9
+ * // In component:
10
+ * ctx.ws.onMessage((data) => ...)
11
+ * ctx.ws.join('conversation:123')
12
+ * ctx.ws.send({ type: 'ping' })
13
+ * ```
14
+ */
15
+ import type { AppMiddleware } from '../types.ts';
16
+ export interface WsOptions {
17
+ url?: string;
18
+ reconnectInterval?: number;
19
+ maxReconnect?: number;
20
+ }
21
+ export declare function ws(opts?: WsOptions): AppMiddleware;
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * wefu 类型定义
3
3
  */
4
+ import type { Signal } from './signal.ts';
4
5
  import type { Component } from './jsx-runtime.ts';
5
6
  /**
6
7
  * wefu 上下文 — 组件通过第二个参数访问
7
8
  *
8
9
  * ```tsx
9
10
  * function MyPage(props, ctx: WfuiContext) {
10
- * ctx.app.navigate('/chat')
11
- * ctx.provide('key', value)
12
- * const val = ctx.inject('key')
11
+ * ctx.user // 当前登录用户
12
+ * ctx.login(email, pw) // 登录
13
+ * ctx.api.get('/users') // API 请求
14
+ * ctx.ws.send(data) // WebSocket
13
15
  * }
14
16
  * ```
15
17
  */
@@ -19,16 +21,44 @@ export interface WfuiContext {
19
21
  params: Record<string, string>;
20
22
  query: Record<string, string>;
21
23
  hash: string;
22
- /** 当前匹配的路由组件(由 router 中间件注入) */
23
24
  component: Component | null;
24
- /** 当前匹配的路由配置 */
25
25
  title?: string;
26
26
  auth?: boolean;
27
27
  };
28
28
  app: {
29
29
  navigate: (path: string) => void;
30
30
  };
31
- /** 跨组件共享数据(类似 React Context / Vue provide/inject) */
31
+ user: {
32
+ id: string;
33
+ email: string;
34
+ name: string;
35
+ role: string;
36
+ avatar?: string;
37
+ } | null;
38
+ token: string | null;
39
+ isAuthenticated: boolean;
40
+ login: (email: string, password: string) => Promise<void>;
41
+ logout: () => void;
42
+ register: (input: {
43
+ email: string;
44
+ name: string;
45
+ password: string;
46
+ }) => Promise<void>;
47
+ api: {
48
+ get<T>(path: string): Promise<T>;
49
+ post<T>(path: string, body?: unknown): Promise<T>;
50
+ put<T>(path: string, body?: unknown): Promise<T>;
51
+ patch<T>(path: string, body?: unknown): Promise<T>;
52
+ delete<T>(path: string): Promise<T>;
53
+ };
54
+ ws: {
55
+ send: (data: unknown) => void;
56
+ onMessage: (handler: (data: unknown) => void) => () => void;
57
+ join: (room: string) => void;
58
+ leave: (room: string) => void;
59
+ isConnected: Signal<boolean>;
60
+ };
61
+ /** 跨组件共享数据 */
32
62
  provide: <T>(key: string, value: T) => void;
33
63
  inject: <T>(key: string) => T | null;
34
64
  /** 中间件注入扩展 */
package/dist/index.d.ts CHANGED
@@ -47,4 +47,6 @@ export type { CMSAPI, CMSOptions, Content, ContentStatus, ContentType, Tag, TagW
47
47
  export { kb, KB } from './kb/index.ts';
48
48
  export type { KBAPI, KBOptions, Document, Chunk, SearchResult, ImportOptions, SearchOptions, } from './kb/types.ts';
49
49
  export type { BaseAPI, BaseDef, BaseOptions, TableSchema, FieldSchema, FieldType, ColumnMap, CreateBaseInput, UpdateBaseInput, QueryOptions, } from './base/types.ts';
50
+ export { ui } from './ui/index.ts';
51
+ export type { UiOptions, UiRenderOptions } from './ui/index.ts';
50
52
  export { requireRole } from './user/types.ts';
package/dist/index.js CHANGED
@@ -4316,6 +4316,51 @@ function kb(opts) {
4316
4316
  mw.__meta = { injects: ["kb"], depends: ["sql"] };
4317
4317
  return mw;
4318
4318
  }
4319
+
4320
+ // src/ui/index.ts
4321
+ function defaultTemplate(title, script, propsJson) {
4322
+ return `<!DOCTYPE html>
4323
+ <html lang="zh-CN">
4324
+ <head>
4325
+ <meta charset="UTF-8">
4326
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
4327
+ <title>${escapeHtml(title)}</title>
4328
+ </head>
4329
+ <body>
4330
+ <div id="root"></div>
4331
+ ${propsJson ? `<script>window.__WFUI_PROPS__=${propsJson}</script>` : ""}
4332
+ <script src="${escapeHtml(script)}"></script>
4333
+ </body>
4334
+ </html>`;
4335
+ }
4336
+ function escapeHtml(s) {
4337
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4338
+ }
4339
+ function ui(opts = {}) {
4340
+ const defaultTitle = opts.title ?? "weifuwu";
4341
+ const defaultScript = opts.script ?? "/static/app.js";
4342
+ const template = opts.template;
4343
+ return async (req, ctx, next) => {
4344
+ ctx.ui = {
4345
+ html(renderOpts = {}) {
4346
+ const title = renderOpts.title ?? defaultTitle;
4347
+ const script = renderOpts.script ?? defaultScript;
4348
+ let propsJson = "";
4349
+ if (renderOpts.props) {
4350
+ try {
4351
+ propsJson = JSON.stringify(renderOpts.props);
4352
+ } catch {
4353
+ }
4354
+ }
4355
+ const body = template ? template.replace(/\{\{title\}\}/g, escapeHtml(title)).replace(/\{\{script\}\}/g, escapeHtml(script)).replace(/\{\{props\}\}/g, propsJson) : defaultTemplate(title, script, propsJson);
4356
+ return new Response(body, {
4357
+ headers: { "Content-Type": "text/html; charset=utf-8" }
4358
+ });
4359
+ }
4360
+ };
4361
+ return next(req, ctx);
4362
+ };
4363
+ }
4319
4364
  export {
4320
4365
  Base,
4321
4366
  CMS,
@@ -4351,6 +4396,7 @@ export {
4351
4396
  serveStatic,
4352
4397
  trace,
4353
4398
  traceElapsed,
4399
+ ui,
4354
4400
  upload,
4355
4401
  user
4356
4402
  };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ui 中间件 — 注入 ctx.ui.html()
3
+ *
4
+ * 返回 SPA HTML shell,加载前端 client bundle。
5
+ *
6
+ * ```ts
7
+ * import { ui, serveStatic } from 'weifuwu'
8
+ *
9
+ * app.use(ui({ title: 'My App', script: '/static/app.js' }))
10
+ * app.get('/static/*', serveStatic('./dist/client'))
11
+ * app.get('*', async (req, ctx) => ctx.ui.html())
12
+ * ```
13
+ */
14
+ import type { Middleware } from '../types.ts';
15
+ declare module '../types.ts' {
16
+ interface Context {
17
+ ui: {
18
+ /** 返回 SPA HTML 页面 */
19
+ html: (opts?: UiRenderOptions) => Response;
20
+ };
21
+ }
22
+ }
23
+ export interface UiOptions {
24
+ /** 页面标题,默认 'weifuwu' */
25
+ title?: string;
26
+ /** Client bundle JS 路径,默认 '/static/app.js' */
27
+ script?: string;
28
+ /** 自定义 HTML 模板,覆盖默认 */
29
+ template?: string;
30
+ }
31
+ export interface UiRenderOptions {
32
+ title?: string;
33
+ script?: string;
34
+ /** 内嵌到页面的初始数据(通过 window.__WFUI_PROPS__ 访问) */
35
+ props?: Record<string, unknown>;
36
+ }
37
+ export declare function ui(opts?: UiOptions): Middleware;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.33.2",
4
+ "version": "0.33.3",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {