weifuwu 0.60.0 → 0.61.0
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 +23 -7
- package/dist/ai/agent.d.ts +29 -1
- package/dist/ai/client.d.ts +15 -0
- package/dist/ai/index.d.ts +2 -1
- package/dist/client/diff.d.ts +15 -0
- package/dist/client/hydration.d.ts +13 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +3 -2214
- package/dist/client/middleware/api.d.ts +2 -0
- package/dist/client/motion.d.ts +8 -0
- package/dist/client/registry.d.ts +34 -0
- package/dist/client/render.d.ts +13 -14
- package/dist/client/types.d.ts +28 -0
- package/dist/client/ui.d.ts +56 -0
- package/dist/components/Badge/Badge.d.ts +1 -1
- package/dist/components/Button/Button.d.ts +3 -0
- package/dist/components/Card/Card.d.ts +2 -0
- package/dist/components/Confirm/Confirm.d.ts +8 -1
- package/dist/components/Dropdown/Dropdown.d.ts +2 -0
- package/dist/components/Icon/Icon.d.ts +19 -0
- package/dist/components/Input/Input.d.ts +5 -0
- package/dist/components/Modal/Modal.d.ts +2 -0
- package/dist/components/PageHeader/PageHeader.d.ts +2 -0
- package/dist/components/StatCard/StatCard.d.ts +2 -0
- package/dist/components/Table/Table.d.ts +4 -0
- package/dist/components/Toast/Toast.d.ts +13 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +1 -3381
- package/dist/components/style.css +282 -134
- package/dist/core/router.d.ts +10 -10
- package/dist/core/serve.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +523 -11
- package/dist/layout/weifuwu-layout.css +106 -17
- package/dist/messager/index.d.ts +107 -0
- package/dist/types.d.ts +2 -2
- package/dist/user/index.d.ts +3 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1513,7 +1513,11 @@ function userSystem(options) {
|
|
|
1513
1513
|
return rows.length ? rows[0] : null;
|
|
1514
1514
|
}
|
|
1515
1515
|
async function issueSession(user) {
|
|
1516
|
-
const token = signToken(
|
|
1516
|
+
const token = signToken(
|
|
1517
|
+
{ sub: user.id, ...user.tenant ? { tenantId: user.tenant } : {} },
|
|
1518
|
+
secret,
|
|
1519
|
+
accessTtlSeconds
|
|
1520
|
+
);
|
|
1517
1521
|
const refreshToken = generateRefreshToken();
|
|
1518
1522
|
const expiresAt = new Date(Date.now() + refreshTtlDays * 24 * 3600 * 1e3);
|
|
1519
1523
|
await sql.unsafe(
|
|
@@ -1539,15 +1543,26 @@ function userSystem(options) {
|
|
|
1539
1543
|
}
|
|
1540
1544
|
const mw = (async (req, ctx, next) => {
|
|
1541
1545
|
currentUser = null;
|
|
1546
|
+
const sessionPayload = {};
|
|
1547
|
+
let payloadTenantId;
|
|
1542
1548
|
const authHeader = req.headers.get("authorization");
|
|
1543
1549
|
if (authHeader?.startsWith("Bearer ")) {
|
|
1544
1550
|
const payload = verifyToken(authHeader.slice(7), secret);
|
|
1545
1551
|
if (payload?.sub) {
|
|
1546
1552
|
currentUser = await findUserById(String(payload.sub));
|
|
1553
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
1554
|
+
if (k === "sub" || k === "iat" || k === "exp" || k === "type") continue;
|
|
1555
|
+
sessionPayload[k] = v;
|
|
1556
|
+
}
|
|
1557
|
+
sessionPayload.userId = String(payload.sub);
|
|
1558
|
+
payloadTenantId = payload.tenantId;
|
|
1547
1559
|
}
|
|
1548
1560
|
}
|
|
1549
1561
|
ctx.user = currentUser;
|
|
1562
|
+
if (payloadTenantId != null) ctx.tenantId = String(payloadTenantId);
|
|
1550
1563
|
ctx.auth = {
|
|
1564
|
+
// ── 会话 payload 字段(来自 token,应用层签发时决定带什么) ──
|
|
1565
|
+
...sessionPayload,
|
|
1551
1566
|
async register(input) {
|
|
1552
1567
|
if (!input.email || !input.password) throw new HttpError("email and password are required", 400);
|
|
1553
1568
|
if (input.password.length < 8) throw new HttpError("password must be at least 8 characters", 400);
|
|
@@ -1630,24 +1645,25 @@ function userSystem(options) {
|
|
|
1630
1645
|
};
|
|
1631
1646
|
mw.routes = (app, routeOpts) => {
|
|
1632
1647
|
const p = routeOpts?.prefix ?? prefix;
|
|
1633
|
-
|
|
1648
|
+
const excluded = new Set(routeOpts?.exclude ?? []);
|
|
1649
|
+
if (!excluded.has("register")) app.post(`${p}/register`, async (req, ctx) => {
|
|
1634
1650
|
const body = await req.json().catch(() => ({}));
|
|
1635
1651
|
const result = await ctx.auth.register(body);
|
|
1636
1652
|
return created(result);
|
|
1637
1653
|
});
|
|
1638
|
-
app.post(`${p}/login`, async (req, ctx) => {
|
|
1654
|
+
if (!excluded.has("login")) app.post(`${p}/login`, async (req, ctx) => {
|
|
1639
1655
|
const body = await req.json().catch(() => ({}));
|
|
1640
1656
|
if (!body.email || !body.password) return badRequest("email and password are required");
|
|
1641
1657
|
const result = await ctx.auth.login(body.email, body.password);
|
|
1642
1658
|
return ok(result);
|
|
1643
1659
|
});
|
|
1644
|
-
app.post(`${p}/logout`, async (req, ctx) => {
|
|
1660
|
+
if (!excluded.has("logout")) app.post(`${p}/logout`, async (req, ctx) => {
|
|
1645
1661
|
const body = await req.json().catch(() => ({}));
|
|
1646
1662
|
if (!body.refreshToken) return badRequest("refreshToken is required");
|
|
1647
1663
|
await ctx.auth.logout(body.refreshToken);
|
|
1648
1664
|
return noContent();
|
|
1649
1665
|
});
|
|
1650
|
-
app.post(`${p}/refresh`, async (req, ctx) => {
|
|
1666
|
+
if (!excluded.has("refresh")) app.post(`${p}/refresh`, async (req, ctx) => {
|
|
1651
1667
|
const body = await req.json().catch(() => ({}));
|
|
1652
1668
|
if (!body.refreshToken) throw new HttpError("refreshToken is required", 400);
|
|
1653
1669
|
const user = await consumeRefreshToken(body.refreshToken);
|
|
@@ -1655,7 +1671,7 @@ function userSystem(options) {
|
|
|
1655
1671
|
const session = await issueSession(user);
|
|
1656
1672
|
return ok({ ...session, user });
|
|
1657
1673
|
});
|
|
1658
|
-
app.get(`${p}/me`, async (_req, ctx) => {
|
|
1674
|
+
if (!excluded.has("me")) app.get(`${p}/me`, async (_req, ctx) => {
|
|
1659
1675
|
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
1660
1676
|
return ok(ctx.user);
|
|
1661
1677
|
});
|
|
@@ -1663,6 +1679,403 @@ function userSystem(options) {
|
|
|
1663
1679
|
return mw;
|
|
1664
1680
|
}
|
|
1665
1681
|
|
|
1682
|
+
// src/messager/index.ts
|
|
1683
|
+
var CONVERSATIONS = "_weifuwu_conversations";
|
|
1684
|
+
var MEMBERS = "_weifuwu_conversation_members";
|
|
1685
|
+
var MESSAGES = "_weifuwu_messages";
|
|
1686
|
+
function messager(options) {
|
|
1687
|
+
const sql = options.sql;
|
|
1688
|
+
const prefix = options.prefix ?? "/api/messages";
|
|
1689
|
+
async function createConversation(userId, input) {
|
|
1690
|
+
if (input.type === "direct") {
|
|
1691
|
+
const existing = await sql.unsafe(
|
|
1692
|
+
`SELECT c.id, c.type, c.created_by, c.created_at FROM ${CONVERSATIONS} c
|
|
1693
|
+
WHERE c.type = 'direct'
|
|
1694
|
+
AND EXISTS (SELECT 1 FROM ${MEMBERS} m WHERE m.conversation_id = c.id AND m.user_id = $1)
|
|
1695
|
+
AND EXISTS (SELECT 1 FROM ${MEMBERS} m WHERE m.conversation_id = c.id AND m.user_id = $2)
|
|
1696
|
+
AND (SELECT count(*) FROM ${MEMBERS} m WHERE m.conversation_id = c.id) = 2
|
|
1697
|
+
LIMIT 1`,
|
|
1698
|
+
[userId, input.otherUserId]
|
|
1699
|
+
);
|
|
1700
|
+
if (existing.length) return existing[0];
|
|
1701
|
+
return createConversationRow(userId, "direct", [userId, input.otherUserId]);
|
|
1702
|
+
}
|
|
1703
|
+
const memberIds = [userId, ...input.memberIds.filter((m) => m !== userId)];
|
|
1704
|
+
return createConversationRow(userId, "group", memberIds);
|
|
1705
|
+
}
|
|
1706
|
+
async function createConversationRow(createdBy, type, memberIds) {
|
|
1707
|
+
await sql.unsafe(`BEGIN`);
|
|
1708
|
+
try {
|
|
1709
|
+
const rows = await sql.unsafe(
|
|
1710
|
+
`INSERT INTO ${CONVERSATIONS} (type, created_by) VALUES ($1, $2) RETURNING id, type, created_by, created_at`,
|
|
1711
|
+
[type, createdBy]
|
|
1712
|
+
);
|
|
1713
|
+
const conv = rows[0];
|
|
1714
|
+
for (const memberId of memberIds) {
|
|
1715
|
+
await sql.unsafe(
|
|
1716
|
+
`INSERT INTO ${MEMBERS} (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
1717
|
+
[conv.id, memberId]
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
await sql.unsafe(`COMMIT`);
|
|
1721
|
+
return conv;
|
|
1722
|
+
} catch (err) {
|
|
1723
|
+
await sql.unsafe(`ROLLBACK`);
|
|
1724
|
+
throw err;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
async function listConversations(userId) {
|
|
1728
|
+
const rows = await sql.unsafe(
|
|
1729
|
+
`SELECT c.id, c.type, c.created_by, c.created_at,
|
|
1730
|
+
(SELECT to_jsonb(m) FROM ${MESSAGES} m
|
|
1731
|
+
WHERE m.conversation_id = c.id AND m.deleted_at IS NULL
|
|
1732
|
+
ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message,
|
|
1733
|
+
(SELECT count(*) FROM ${MESSAGES} m
|
|
1734
|
+
WHERE m.conversation_id = c.id AND m.deleted_at IS NULL
|
|
1735
|
+
AND m.sender_id IS DISTINCT FROM $1
|
|
1736
|
+
AND m.created_at > COALESCE(mem.last_read_at, 'epoch'::timestamptz)) AS unread_count
|
|
1737
|
+
FROM ${CONVERSATIONS} c
|
|
1738
|
+
JOIN ${MEMBERS} mem ON mem.conversation_id = c.id
|
|
1739
|
+
WHERE mem.user_id = $1
|
|
1740
|
+
ORDER BY c.created_at DESC`,
|
|
1741
|
+
[userId]
|
|
1742
|
+
);
|
|
1743
|
+
return rows.map((r) => {
|
|
1744
|
+
const conv = r;
|
|
1745
|
+
const last = conv.last_message;
|
|
1746
|
+
return {
|
|
1747
|
+
id: conv.id,
|
|
1748
|
+
type: conv.type,
|
|
1749
|
+
created_by: conv.created_by,
|
|
1750
|
+
created_at: new Date(conv.created_at).toISOString(),
|
|
1751
|
+
last_message: last && typeof last === "object" ? normalizeMessage(last) : null,
|
|
1752
|
+
unread_count: Number(conv.unread_count)
|
|
1753
|
+
};
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
async function getConversationForUser(conversationId, userId) {
|
|
1757
|
+
if (!await isMember(conversationId, userId)) return null;
|
|
1758
|
+
const rows = await sql.unsafe(
|
|
1759
|
+
`SELECT id, type, created_by, created_at FROM ${CONVERSATIONS} WHERE id = $1`,
|
|
1760
|
+
[conversationId]
|
|
1761
|
+
);
|
|
1762
|
+
return rows.length ? rows[0] : null;
|
|
1763
|
+
}
|
|
1764
|
+
async function isMember(conversationId, userId) {
|
|
1765
|
+
const rows = await sql.unsafe(
|
|
1766
|
+
`SELECT 1 FROM ${MEMBERS} WHERE conversation_id = $1 AND user_id = $2`,
|
|
1767
|
+
[conversationId, userId]
|
|
1768
|
+
);
|
|
1769
|
+
return rows.length > 0;
|
|
1770
|
+
}
|
|
1771
|
+
async function sendMessage(conversationId, input) {
|
|
1772
|
+
const rows = await sql.unsafe(
|
|
1773
|
+
`INSERT INTO ${MESSAGES} (conversation_id, sender_type, sender_id, content, msg_type)
|
|
1774
|
+
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
|
1775
|
+
[conversationId, input.senderType, input.senderId ?? null, input.content, input.msgType ?? "text"]
|
|
1776
|
+
);
|
|
1777
|
+
return normalizeMessage(rows[0]);
|
|
1778
|
+
}
|
|
1779
|
+
async function listMessages(conversationId, opts) {
|
|
1780
|
+
const limit = opts.limit ?? 50;
|
|
1781
|
+
const rows = await sql.unsafe(
|
|
1782
|
+
`SELECT * FROM ${MESSAGES}
|
|
1783
|
+
WHERE conversation_id = $1
|
|
1784
|
+
AND ($2::uuid IS NULL OR (created_at, id) < (SELECT created_at, id FROM ${MESSAGES} WHERE id = $2))
|
|
1785
|
+
ORDER BY created_at DESC, id DESC
|
|
1786
|
+
LIMIT $3`,
|
|
1787
|
+
[conversationId, opts.before ?? null, limit]
|
|
1788
|
+
);
|
|
1789
|
+
return rows.map(normalizeMessage);
|
|
1790
|
+
}
|
|
1791
|
+
async function editMessage(messageId, content) {
|
|
1792
|
+
const rows = await sql.unsafe(
|
|
1793
|
+
`UPDATE ${MESSAGES} SET content = $2, edited_at = now()
|
|
1794
|
+
WHERE id = $1 AND deleted_at IS NULL RETURNING *`,
|
|
1795
|
+
[messageId, content]
|
|
1796
|
+
);
|
|
1797
|
+
return rows.length ? normalizeMessage(rows[0]) : null;
|
|
1798
|
+
}
|
|
1799
|
+
async function deleteMessage(messageId) {
|
|
1800
|
+
const rows = await sql.unsafe(
|
|
1801
|
+
`UPDATE ${MESSAGES} SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL RETURNING id`,
|
|
1802
|
+
[messageId]
|
|
1803
|
+
);
|
|
1804
|
+
return rows.length > 0;
|
|
1805
|
+
}
|
|
1806
|
+
async function markRead(conversationId, userId) {
|
|
1807
|
+
await sql.unsafe(
|
|
1808
|
+
`UPDATE ${MEMBERS} SET last_read_at = now() WHERE conversation_id = $1 AND user_id = $2`,
|
|
1809
|
+
[conversationId, userId]
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
function normalizeMessage(row) {
|
|
1813
|
+
return {
|
|
1814
|
+
id: row.id,
|
|
1815
|
+
conversation_id: row.conversation_id,
|
|
1816
|
+
sender_type: row.sender_type,
|
|
1817
|
+
sender_id: row.sender_id,
|
|
1818
|
+
content: row.content,
|
|
1819
|
+
msg_type: row.msg_type,
|
|
1820
|
+
created_at: new Date(row.created_at).toISOString(),
|
|
1821
|
+
edited_at: row.edited_at ? new Date(row.edited_at).toISOString() : null,
|
|
1822
|
+
deleted_at: row.deleted_at ? new Date(row.deleted_at).toISOString() : null
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
const REDIS_PREFIX = "wf:msg:";
|
|
1826
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
1827
|
+
const wsRooms = /* @__PURE__ */ new Map();
|
|
1828
|
+
let redisSub = null;
|
|
1829
|
+
function initRedis() {
|
|
1830
|
+
const pool = options.redis?.redis;
|
|
1831
|
+
if (!pool || redisSub) return;
|
|
1832
|
+
redisSub = pool.createSubscriber();
|
|
1833
|
+
redisSub.connect().then(() => {
|
|
1834
|
+
redisSub.psubscribe(`${REDIS_PREFIX}*`, (channel, message) => {
|
|
1835
|
+
const room = channel.slice(REDIS_PREFIX.length);
|
|
1836
|
+
const event = JSON.parse(message);
|
|
1837
|
+
broadcastLocal(room, event);
|
|
1838
|
+
});
|
|
1839
|
+
}).catch((err) => {
|
|
1840
|
+
console.error("[messager] redis subscriber init error:", err);
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
function broadcastLocal(room, event) {
|
|
1844
|
+
const members = rooms.get(room);
|
|
1845
|
+
if (!members) return;
|
|
1846
|
+
const payload = JSON.stringify(event);
|
|
1847
|
+
for (const ws of [...members]) {
|
|
1848
|
+
if (ws.readyState === ws.OPEN) {
|
|
1849
|
+
try {
|
|
1850
|
+
ws.send(payload);
|
|
1851
|
+
} catch {
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
function broadcast(room, event) {
|
|
1857
|
+
broadcastLocal(room, event);
|
|
1858
|
+
const pool = options.redis?.redis;
|
|
1859
|
+
if (pool) {
|
|
1860
|
+
pool.publish(`${REDIS_PREFIX}${room}`, JSON.stringify(event)).catch((err) => {
|
|
1861
|
+
console.error("[messager] redis publish error:", err);
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
function sendTo(userId, event) {
|
|
1866
|
+
broadcast(`user:${userId}`, event);
|
|
1867
|
+
}
|
|
1868
|
+
function join(room, ws) {
|
|
1869
|
+
let members = rooms.get(room);
|
|
1870
|
+
if (!members) {
|
|
1871
|
+
members = /* @__PURE__ */ new Set();
|
|
1872
|
+
rooms.set(room, members);
|
|
1873
|
+
}
|
|
1874
|
+
members.add(ws);
|
|
1875
|
+
let joined = wsRooms.get(ws);
|
|
1876
|
+
if (!joined) {
|
|
1877
|
+
joined = /* @__PURE__ */ new Set();
|
|
1878
|
+
wsRooms.set(ws, joined);
|
|
1879
|
+
}
|
|
1880
|
+
joined.add(room);
|
|
1881
|
+
}
|
|
1882
|
+
function leave(room, ws) {
|
|
1883
|
+
rooms.get(room)?.delete(ws);
|
|
1884
|
+
if (rooms.get(room)?.size === 0) rooms.delete(room);
|
|
1885
|
+
wsRooms.get(ws)?.delete(room);
|
|
1886
|
+
}
|
|
1887
|
+
function leaveAll(ws) {
|
|
1888
|
+
const joined = wsRooms.get(ws);
|
|
1889
|
+
if (joined) for (const room of [...joined]) leave(room, ws);
|
|
1890
|
+
wsRooms.delete(ws);
|
|
1891
|
+
}
|
|
1892
|
+
function handler() {
|
|
1893
|
+
return {
|
|
1894
|
+
open(ws) {
|
|
1895
|
+
ws.send(JSON.stringify({ type: "connected" }));
|
|
1896
|
+
},
|
|
1897
|
+
message(ws, _ctx, data) {
|
|
1898
|
+
try {
|
|
1899
|
+
const msg = JSON.parse(data.toString());
|
|
1900
|
+
if (msg.type === "ping") {
|
|
1901
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
1902
|
+
} else if (msg.type === "subscribe" && msg.room) {
|
|
1903
|
+
join(msg.room, ws);
|
|
1904
|
+
ws.send(JSON.stringify({ type: "subscribed", room: msg.room }));
|
|
1905
|
+
} else if (msg.type === "unsubscribe" && msg.room) {
|
|
1906
|
+
leave(msg.room, ws);
|
|
1907
|
+
}
|
|
1908
|
+
} catch {
|
|
1909
|
+
}
|
|
1910
|
+
},
|
|
1911
|
+
close(ws) {
|
|
1912
|
+
leaveAll(ws);
|
|
1913
|
+
},
|
|
1914
|
+
error(ws) {
|
|
1915
|
+
leaveAll(ws);
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
async function close() {
|
|
1920
|
+
if (redisSub) {
|
|
1921
|
+
try {
|
|
1922
|
+
await redisSub.close?.();
|
|
1923
|
+
} catch {
|
|
1924
|
+
}
|
|
1925
|
+
redisSub = null;
|
|
1926
|
+
}
|
|
1927
|
+
rooms.clear();
|
|
1928
|
+
wsRooms.clear();
|
|
1929
|
+
}
|
|
1930
|
+
const client = {
|
|
1931
|
+
handler,
|
|
1932
|
+
broadcast,
|
|
1933
|
+
sendTo,
|
|
1934
|
+
join,
|
|
1935
|
+
leave,
|
|
1936
|
+
close,
|
|
1937
|
+
createConversation,
|
|
1938
|
+
listConversations,
|
|
1939
|
+
getConversationForUser,
|
|
1940
|
+
isMember,
|
|
1941
|
+
sendMessage,
|
|
1942
|
+
listMessages,
|
|
1943
|
+
editMessage,
|
|
1944
|
+
deleteMessage,
|
|
1945
|
+
markRead
|
|
1946
|
+
};
|
|
1947
|
+
initRedis();
|
|
1948
|
+
const mw = (async (req, ctx, next) => {
|
|
1949
|
+
ctx.msg = client;
|
|
1950
|
+
return next(req, ctx);
|
|
1951
|
+
});
|
|
1952
|
+
mw.client = client;
|
|
1953
|
+
mw.__meta = { injects: ["msg"], depends: ["sql"] };
|
|
1954
|
+
mw.migrate = async () => {
|
|
1955
|
+
await sql.unsafe(`
|
|
1956
|
+
CREATE TABLE IF NOT EXISTS ${CONVERSATIONS} (
|
|
1957
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1958
|
+
type TEXT NOT NULL DEFAULT 'direct',
|
|
1959
|
+
created_by UUID,
|
|
1960
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
1961
|
+
)
|
|
1962
|
+
`);
|
|
1963
|
+
await sql.unsafe(`
|
|
1964
|
+
CREATE TABLE IF NOT EXISTS ${MEMBERS} (
|
|
1965
|
+
conversation_id UUID NOT NULL REFERENCES ${CONVERSATIONS}(id) ON DELETE CASCADE,
|
|
1966
|
+
user_id UUID NOT NULL,
|
|
1967
|
+
last_read_at TIMESTAMPTZ,
|
|
1968
|
+
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1969
|
+
PRIMARY KEY (conversation_id, user_id)
|
|
1970
|
+
)
|
|
1971
|
+
`);
|
|
1972
|
+
await sql.unsafe(`
|
|
1973
|
+
CREATE TABLE IF NOT EXISTS ${MESSAGES} (
|
|
1974
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1975
|
+
conversation_id UUID NOT NULL REFERENCES ${CONVERSATIONS}(id) ON DELETE CASCADE,
|
|
1976
|
+
sender_type TEXT NOT NULL DEFAULT 'user',
|
|
1977
|
+
sender_id UUID,
|
|
1978
|
+
content TEXT NOT NULL,
|
|
1979
|
+
msg_type TEXT NOT NULL DEFAULT 'text',
|
|
1980
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1981
|
+
edited_at TIMESTAMPTZ,
|
|
1982
|
+
deleted_at TIMESTAMPTZ
|
|
1983
|
+
)
|
|
1984
|
+
`);
|
|
1985
|
+
await sql.unsafe(`
|
|
1986
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conv ON ${MESSAGES} (conversation_id, created_at DESC, id DESC)
|
|
1987
|
+
`);
|
|
1988
|
+
};
|
|
1989
|
+
mw.routes = (app, routeOpts) => {
|
|
1990
|
+
const p = routeOpts?.prefix ?? prefix;
|
|
1991
|
+
async function requireMember(conversationId, ctx) {
|
|
1992
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
1993
|
+
if (!await isMember(conversationId, ctx.user.id)) {
|
|
1994
|
+
throw new HttpError("Forbidden: not a conversation member", 403);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
app.post(`${p}/conversations`, async (req, ctx) => {
|
|
1998
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
1999
|
+
const body = await req.json().catch(() => ({}));
|
|
2000
|
+
if (body.type === "direct" && body.otherUserId) {
|
|
2001
|
+
const conv = await createConversation(ctx.user.id, { type: "direct", otherUserId: body.otherUserId });
|
|
2002
|
+
return created(conv);
|
|
2003
|
+
}
|
|
2004
|
+
if (body.type === "group" && Array.isArray(body.memberIds)) {
|
|
2005
|
+
const conv = await createConversation(ctx.user.id, { type: "group", memberIds: body.memberIds });
|
|
2006
|
+
return created(conv);
|
|
2007
|
+
}
|
|
2008
|
+
return badRequest("conversation input invalid");
|
|
2009
|
+
});
|
|
2010
|
+
app.get(`${p}/conversations`, async (_req, ctx) => {
|
|
2011
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
2012
|
+
return ok(await listConversations(ctx.user.id));
|
|
2013
|
+
});
|
|
2014
|
+
app.get(`${p}/conversations/:id/messages`, async (req, ctx) => {
|
|
2015
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
2016
|
+
const convId = ctx.params.id;
|
|
2017
|
+
await requireMember(convId, ctx);
|
|
2018
|
+
const url = new URL(req.url);
|
|
2019
|
+
const before = url.searchParams.get("before") ?? void 0;
|
|
2020
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
2021
|
+
return ok(await listMessages(convId, { before, limit }));
|
|
2022
|
+
});
|
|
2023
|
+
app.post(`${p}/conversations/:id/messages`, async (req, ctx) => {
|
|
2024
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
2025
|
+
const convId = ctx.params.id;
|
|
2026
|
+
await requireMember(convId, ctx);
|
|
2027
|
+
const body = await req.json().catch(() => ({}));
|
|
2028
|
+
if (!body.content?.trim()) return badRequest("content is required");
|
|
2029
|
+
const message = await sendMessage(convId, {
|
|
2030
|
+
senderType: "user",
|
|
2031
|
+
senderId: ctx.user.id,
|
|
2032
|
+
content: body.content,
|
|
2033
|
+
msgType: body.msgType ?? "text"
|
|
2034
|
+
});
|
|
2035
|
+
broadcast(`conv:${convId}`, { type: "new_message", message });
|
|
2036
|
+
return created(message);
|
|
2037
|
+
});
|
|
2038
|
+
app.post(`${p}/conversations/:id/read`, async (_req, ctx) => {
|
|
2039
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
2040
|
+
const convId = ctx.params.id;
|
|
2041
|
+
await requireMember(convId, ctx);
|
|
2042
|
+
await markRead(convId, ctx.user.id);
|
|
2043
|
+
return noContent();
|
|
2044
|
+
});
|
|
2045
|
+
app.patch(`${p}/messages/:id`, async (req, ctx) => {
|
|
2046
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
2047
|
+
const messageId = ctx.params.id;
|
|
2048
|
+
const body = await req.json().catch(() => ({}));
|
|
2049
|
+
if (!body.content?.trim()) return badRequest("content is required");
|
|
2050
|
+
const edited = await editMessage(messageId, body.content);
|
|
2051
|
+
if (!edited) return badRequest("message not found");
|
|
2052
|
+
if (edited.sender_type === "user" && edited.sender_id !== ctx.user.id) {
|
|
2053
|
+
throw new HttpError("Forbidden: not your message", 403);
|
|
2054
|
+
}
|
|
2055
|
+
broadcast(`conv:${edited.conversation_id}`, { type: "message_edited", message: edited });
|
|
2056
|
+
return ok(edited);
|
|
2057
|
+
});
|
|
2058
|
+
app.delete(`${p}/messages/:id`, async (req, ctx) => {
|
|
2059
|
+
if (!ctx.user) throw new HttpError("Unauthorized", 401);
|
|
2060
|
+
const messageId = ctx.params.id;
|
|
2061
|
+
const list = await sql.unsafe(
|
|
2062
|
+
`SELECT conversation_id, sender_type, sender_id FROM ${MESSAGES} WHERE id = $1`,
|
|
2063
|
+
[messageId]
|
|
2064
|
+
);
|
|
2065
|
+
if (!list.length) return badRequest("message not found");
|
|
2066
|
+
const row = list[0];
|
|
2067
|
+
if (row.sender_type === "user" && row.sender_id !== ctx.user.id) {
|
|
2068
|
+
throw new HttpError("Forbidden: not your message", 403);
|
|
2069
|
+
}
|
|
2070
|
+
await requireMember(row.conversation_id, ctx);
|
|
2071
|
+
await deleteMessage(messageId);
|
|
2072
|
+
broadcast(`conv:${row.conversation_id}`, { type: "message_deleted", messageId });
|
|
2073
|
+
return noContent();
|
|
2074
|
+
});
|
|
2075
|
+
};
|
|
2076
|
+
return mw;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1666
2079
|
// src/queue/index.ts
|
|
1667
2080
|
import { randomUUID } from "node:crypto";
|
|
1668
2081
|
import { hostname } from "node:os";
|
|
@@ -2674,9 +3087,56 @@ async function providerError(res) {
|
|
|
2674
3087
|
else code = "provider_error";
|
|
2675
3088
|
return { code, message: message || `provider error (${status})` };
|
|
2676
3089
|
}
|
|
3090
|
+
function createEmbeddingClient(ebd) {
|
|
3091
|
+
const apiKey = ebd?.apiKey ?? process.env.DASHSCOPE_API_KEY ?? "";
|
|
3092
|
+
const configured = !!(ebd?.apiKey || process.env.DASHSCOPE_API_KEY);
|
|
3093
|
+
const baseUrl = ebd?.baseUrl ?? process.env.DASHSCOPE_BASE_URL ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
3094
|
+
const defaultModel = ebd?.defaultModel ?? process.env.DASHSCOPE_EMBEDDING_MODEL ?? "text-embedding-v4";
|
|
3095
|
+
const endpoint = `${baseUrl.replace(/\/$/, "")}/embeddings`;
|
|
3096
|
+
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
|
3097
|
+
async function embedMany(texts) {
|
|
3098
|
+
if (!configured) {
|
|
3099
|
+
throw new AiError("unsupported", "ai embedding: \u672A\u914D\u7F6E\u2014\u2014\u4F20 ai({ embedding }) \u6216\u8BBE DASHSCOPE_API_KEY\uFF08DeepSeek \u65E0 embedding API\uFF0C\u9700\u72EC\u7ACB provider\uFF09");
|
|
3100
|
+
}
|
|
3101
|
+
if (!apiKey) {
|
|
3102
|
+
throw new AiError("auth_failed", "ai embedding: DASHSCOPE_API_KEY \u672A\u8BBE\u7F6E");
|
|
3103
|
+
}
|
|
3104
|
+
const controller = new AbortController();
|
|
3105
|
+
const timer = setTimeout(() => controller.abort(), 3e3);
|
|
3106
|
+
let res;
|
|
3107
|
+
try {
|
|
3108
|
+
res = await fetch(endpoint, {
|
|
3109
|
+
method: "POST",
|
|
3110
|
+
headers,
|
|
3111
|
+
body: JSON.stringify({ model: defaultModel, input: texts }),
|
|
3112
|
+
signal: controller.signal
|
|
3113
|
+
});
|
|
3114
|
+
} catch (err) {
|
|
3115
|
+
if (controller.signal.aborted) throw new AiError("provider_error", "embedding \u8BF7\u6C42\u8D85\u65F6\uFF083s\uFF09");
|
|
3116
|
+
throw new AiError("provider_error", err instanceof Error ? err.message : String(err));
|
|
3117
|
+
} finally {
|
|
3118
|
+
clearTimeout(timer);
|
|
3119
|
+
}
|
|
3120
|
+
if (!res.ok) {
|
|
3121
|
+
const { code, message } = await providerError(res);
|
|
3122
|
+
throw new AiError(code, message);
|
|
3123
|
+
}
|
|
3124
|
+
const data = await res.json();
|
|
3125
|
+
data.data.sort((a, b) => a.index - b.index);
|
|
3126
|
+
return data.data.map((item) => item.embedding);
|
|
3127
|
+
}
|
|
3128
|
+
return {
|
|
3129
|
+
async embed(text) {
|
|
3130
|
+
const results = await embedMany([text]);
|
|
3131
|
+
return results[0];
|
|
3132
|
+
},
|
|
3133
|
+
embedMany
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
2677
3136
|
function createAiClient(opts) {
|
|
2678
3137
|
const endpoint = `${opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
2679
3138
|
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${opts.apiKey}` };
|
|
3139
|
+
const embedding = createEmbeddingClient(opts.embedding);
|
|
2680
3140
|
const approvals = /* @__PURE__ */ new Map();
|
|
2681
3141
|
function approve(response) {
|
|
2682
3142
|
const resolve3 = approvals.get(response.id);
|
|
@@ -2810,7 +3270,17 @@ function createAiClient(opts) {
|
|
|
2810
3270
|
}
|
|
2811
3271
|
return sseResponse(run, { onAbort: () => controller.abort() });
|
|
2812
3272
|
}
|
|
2813
|
-
return {
|
|
3273
|
+
return {
|
|
3274
|
+
chat,
|
|
3275
|
+
stream,
|
|
3276
|
+
sse,
|
|
3277
|
+
streamStep,
|
|
3278
|
+
waitApproval,
|
|
3279
|
+
approve,
|
|
3280
|
+
// embedding:未配置 provider 时明确抛 AiError(诚实裁剪:不静默降级)
|
|
3281
|
+
embed: (text) => embedding.embed(text),
|
|
3282
|
+
embedMany: (texts) => embedding.embedMany(texts)
|
|
3283
|
+
};
|
|
2814
3284
|
}
|
|
2815
3285
|
var DEFAULT_APPROVAL_TIMEOUT = 5 * 6e4;
|
|
2816
3286
|
function safeParseArgs(raw) {
|
|
@@ -2830,13 +3300,23 @@ function createAgent(client, config) {
|
|
|
2830
3300
|
type: "function",
|
|
2831
3301
|
function: { name: t.name, description: t.description ?? "", parameters: t.parameters ?? {} }
|
|
2832
3302
|
}));
|
|
2833
|
-
function
|
|
3303
|
+
function createController(external) {
|
|
2834
3304
|
const controller = new AbortController();
|
|
2835
|
-
const external = options?.signal;
|
|
2836
3305
|
if (external) {
|
|
2837
3306
|
if (external.aborted) controller.abort();
|
|
2838
3307
|
else external.addEventListener("abort", () => controller.abort(), { once: true });
|
|
2839
3308
|
}
|
|
3309
|
+
return controller;
|
|
3310
|
+
}
|
|
3311
|
+
async function stream(messages, options) {
|
|
3312
|
+
const controller = createController(options?.signal);
|
|
3313
|
+
const emit = options?.emit ?? (() => {
|
|
3314
|
+
});
|
|
3315
|
+
emit("wf:message_start", { id: options?.traceId ?? randomUUID3() });
|
|
3316
|
+
await loop(messages, emit, controller.signal);
|
|
3317
|
+
}
|
|
3318
|
+
function run(messages, options) {
|
|
3319
|
+
const controller = createController(options?.signal);
|
|
2840
3320
|
return sseResponse(
|
|
2841
3321
|
async (emit) => {
|
|
2842
3322
|
emit("wf:message_start", { id: options?.traceId ?? randomUUID3() });
|
|
@@ -2845,6 +3325,35 @@ function createAgent(client, config) {
|
|
|
2845
3325
|
{ onAbort: () => controller.abort() }
|
|
2846
3326
|
);
|
|
2847
3327
|
}
|
|
3328
|
+
async function runToResult(messages, options) {
|
|
3329
|
+
let content = "";
|
|
3330
|
+
const steps = [];
|
|
3331
|
+
let usage;
|
|
3332
|
+
const emit = (name, data) => {
|
|
3333
|
+
if (name === "wf:token") {
|
|
3334
|
+
content += data.text;
|
|
3335
|
+
} else if (name === "wf:step") {
|
|
3336
|
+
const s = data;
|
|
3337
|
+
if (s.type === "llm") steps.push({ type: "llm", content: s.content });
|
|
3338
|
+
else steps.push({ type: "tool_call", toolCall: { id: s.toolCallId ?? "", name: s.name ?? "", arguments: "" } });
|
|
3339
|
+
} else if (name === "wf:tool_result") {
|
|
3340
|
+
const r = data;
|
|
3341
|
+
const last = steps[steps.length - 1];
|
|
3342
|
+
if (last && last.type === "tool_call") {
|
|
3343
|
+
steps[steps.length - 1] = {
|
|
3344
|
+
type: "tool_result",
|
|
3345
|
+
toolCall: last.toolCall,
|
|
3346
|
+
toolResult: r.ok ? JSON.stringify(r.output ?? "") : `Error: ${r.error?.message ?? "unknown"}`
|
|
3347
|
+
};
|
|
3348
|
+
}
|
|
3349
|
+
} else if (name === "wf:usage") {
|
|
3350
|
+
usage = data;
|
|
3351
|
+
}
|
|
3352
|
+
};
|
|
3353
|
+
const controller = createController(options?.signal);
|
|
3354
|
+
await loop(messages, emit, controller.signal);
|
|
3355
|
+
return { content, steps, usage };
|
|
3356
|
+
}
|
|
2848
3357
|
async function loop(messages, emit, signal) {
|
|
2849
3358
|
const all = [{ role: "system", content: config.systemPrompt }, ...messages];
|
|
2850
3359
|
const usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
@@ -2920,7 +3429,7 @@ function createAgent(client, config) {
|
|
|
2920
3429
|
emit("wf:usage", usage);
|
|
2921
3430
|
emit("wf:done", { content: "", usage });
|
|
2922
3431
|
}
|
|
2923
|
-
return { run };
|
|
3432
|
+
return { run, stream, runToResult };
|
|
2924
3433
|
}
|
|
2925
3434
|
|
|
2926
3435
|
// src/ai/index.ts
|
|
@@ -2931,7 +3440,7 @@ function ai(options) {
|
|
|
2931
3440
|
if (!apiKey) {
|
|
2932
3441
|
throw new Error("ai: DEEPSEEK_API_KEY \u672A\u8BBE\u7F6E\u3002\u8BF7\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF\u6216\u4F20\u5165 apiKey");
|
|
2933
3442
|
}
|
|
2934
|
-
const client = createAiClient({ apiKey, baseUrl, defaultModel });
|
|
3443
|
+
const client = createAiClient({ apiKey, baseUrl, defaultModel, embedding: options?.embedding });
|
|
2935
3444
|
const mw = (req, ctx, next) => {
|
|
2936
3445
|
ctx.ai = module;
|
|
2937
3446
|
return next(req, ctx);
|
|
@@ -2945,6 +3454,8 @@ function ai(options) {
|
|
|
2945
3454
|
module.waitApproval = client.waitApproval;
|
|
2946
3455
|
module.approve = client.approve;
|
|
2947
3456
|
module.agent = (config) => createAgent(client, config);
|
|
3457
|
+
module.embed = client.embed;
|
|
3458
|
+
module.embedMany = client.embedMany;
|
|
2948
3459
|
module.close = async () => {
|
|
2949
3460
|
};
|
|
2950
3461
|
return module;
|
|
@@ -4563,6 +5074,7 @@ export {
|
|
|
4563
5074
|
created,
|
|
4564
5075
|
email,
|
|
4565
5076
|
forbidden,
|
|
5077
|
+
messager,
|
|
4566
5078
|
noContent,
|
|
4567
5079
|
notFound,
|
|
4568
5080
|
ok,
|