@coffer-org/server 2.4.0 → 2.5.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/dist/entity-schema.d.ts +1 -0
- package/dist/entity-schema.js +14 -1
- package/dist/index.js +55 -3
- package/dist/msg-log.d.ts +10 -0
- package/dist/msg-log.js +16 -0
- package/dist/record-activity.d.ts +11 -0
- package/dist/record-activity.js +118 -0
- package/dist/records-api.js +2 -0
- package/package.json +1 -1
package/dist/entity-schema.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare const MigrationRowSchema: EntitySchema<any, never, import("@mikro
|
|
|
16
16
|
export declare const SeedRowSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
|
|
17
17
|
export declare const EmbeddingSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
|
|
18
18
|
export declare const PluginStateSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
|
|
19
|
+
export declare const RecordActivitySchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
|
|
19
20
|
export declare const ThreadMessageSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
|
|
20
21
|
export declare function buildPluginEntities(plugins: PluginManifest[]): EntitySchema[];
|
|
21
22
|
export declare const MsgLogSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
|
package/dist/entity-schema.js
CHANGED
|
@@ -152,6 +152,19 @@ export const PluginStateSchema = new EntitySchema({
|
|
|
152
152
|
value: { type: 'text' },
|
|
153
153
|
},
|
|
154
154
|
});
|
|
155
|
+
export const RecordActivitySchema = new EntitySchema({
|
|
156
|
+
name: '_RecordActivity',
|
|
157
|
+
tableName: '_record_activity',
|
|
158
|
+
properties: {
|
|
159
|
+
user_id: { type: 'integer', primary: true },
|
|
160
|
+
library: { type: 'text', primary: true },
|
|
161
|
+
shelf: { type: 'text', primary: true },
|
|
162
|
+
record_id: { type: 'integer', primary: true },
|
|
163
|
+
favorite: { type: 'integer' },
|
|
164
|
+
view_count: { type: 'integer' },
|
|
165
|
+
last_viewed_at: { type: 'text', nullable: true },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
155
168
|
export const ThreadMessageSchema = new EntitySchema({
|
|
156
169
|
name: '_ThreadMessage',
|
|
157
170
|
tableName: '_thread_message',
|
|
@@ -268,7 +281,7 @@ export const OAuthTokenSchema = new EntitySchema({
|
|
|
268
281
|
});
|
|
269
282
|
export const systemEntities = [
|
|
270
283
|
EventSchema, PluginRowSchema, MigrationRowSchema, SeedRowSchema,
|
|
271
|
-
EmbeddingSchema, PluginStateSchema, MsgLogSchema,
|
|
284
|
+
EmbeddingSchema, PluginStateSchema, RecordActivitySchema, MsgLogSchema,
|
|
272
285
|
UserSchema, SessionSchema, ApiTokenSchema,
|
|
273
286
|
OAuthClientSchema, OAuthCodeSchema, OAuthTokenSchema,
|
|
274
287
|
ThreadMessageSchema,
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { verifyUploadTicket } from "./upload-ticket.js";
|
|
|
34
34
|
import { setPluginState } from "./plugin-state.js";
|
|
35
35
|
import { SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY } from "./search-indexer.js";
|
|
36
36
|
import { globalSearch } from "./global-search.js";
|
|
37
|
+
import { applySmartRanking, getRecordActivity, markRecordViewed, setRecordFavorite } from "./record-activity.js";
|
|
37
38
|
const ENV_FILE = join(process.cwd(), '.env');
|
|
38
39
|
if (existsSync(ENV_FILE))
|
|
39
40
|
process.loadEnvFile(ENV_FILE);
|
|
@@ -363,6 +364,11 @@ function maskRecordRow(library, shelf, row) {
|
|
|
363
364
|
}
|
|
364
365
|
return out;
|
|
365
366
|
}
|
|
367
|
+
function tracksRecordActivity(req) {
|
|
368
|
+
const marker = req.headers['x-coffer-automated'];
|
|
369
|
+
const value = Array.isArray(marker) ? marker[0] : marker;
|
|
370
|
+
return value?.toLowerCase().trim() !== 'true';
|
|
371
|
+
}
|
|
366
372
|
app.get('/api/trash', async (_req, reply) => {
|
|
367
373
|
const rows = await recordTrashList();
|
|
368
374
|
return rows.map((entry) => ({ ...entry, record: maskRecordRow(entry.library, entry.shelf, entry.record) }));
|
|
@@ -410,14 +416,19 @@ app.get('/api/:library/:shelf', async (req, reply) => {
|
|
|
410
416
|
};
|
|
411
417
|
try {
|
|
412
418
|
if (!paged) {
|
|
413
|
-
|
|
419
|
+
let rows = await recordList(library, shelf, query, opts);
|
|
420
|
+
if (req.user && tracksRecordActivity(req))
|
|
421
|
+
rows = await applySmartRanking(req.user.id, library, shelf, rows);
|
|
414
422
|
return rows.map((r) => maskRecordRow(library, shelf, r));
|
|
415
423
|
}
|
|
416
|
-
|
|
424
|
+
let page = await recordListPage(library, shelf, query, {
|
|
417
425
|
...opts,
|
|
418
426
|
limit: limit === undefined ? undefined : Number(limit),
|
|
419
427
|
offset: offset === undefined ? undefined : Number(offset),
|
|
420
428
|
});
|
|
429
|
+
if (req.user && tracksRecordActivity(req)) {
|
|
430
|
+
page = { ...page, rows: await applySmartRanking(req.user.id, library, shelf, page.rows) };
|
|
431
|
+
}
|
|
421
432
|
return { rows: page.rows.map((r) => maskRecordRow(library, shelf, r)), total: page.total };
|
|
422
433
|
}
|
|
423
434
|
catch (e) {
|
|
@@ -426,6 +437,43 @@ app.get('/api/:library/:shelf', async (req, reply) => {
|
|
|
426
437
|
throw e;
|
|
427
438
|
}
|
|
428
439
|
});
|
|
440
|
+
app.get('/api/:library/:shelf/:id/activity', async (req, reply) => {
|
|
441
|
+
const { library, shelf, id } = req.params;
|
|
442
|
+
const rid = Number(id);
|
|
443
|
+
if (isNaN(rid))
|
|
444
|
+
return reply.code(400).send({ error: 'invalid_id' });
|
|
445
|
+
try {
|
|
446
|
+
const row = await recordGet(library, shelf, rid);
|
|
447
|
+
if (!row)
|
|
448
|
+
return reply.code(404).send({ error: 'not_found' });
|
|
449
|
+
return { activity: await getRecordActivity(req.user.id, library, shelf, rid) };
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
if (e instanceof UnknownShelfError)
|
|
453
|
+
return reply.code(404).send({ error: 'unknown_shelf' });
|
|
454
|
+
throw e;
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
app.patch('/api/:library/:shelf/:id/activity', async (req, reply) => {
|
|
458
|
+
const { library, shelf, id } = req.params;
|
|
459
|
+
const rid = Number(id);
|
|
460
|
+
if (isNaN(rid))
|
|
461
|
+
return reply.code(400).send({ error: 'invalid_id' });
|
|
462
|
+
const body = (req.body ?? {});
|
|
463
|
+
if (typeof body.favorite !== 'boolean')
|
|
464
|
+
return reply.code(400).send({ error: 'favorite_must_be_boolean' });
|
|
465
|
+
try {
|
|
466
|
+
const row = await recordGet(library, shelf, rid);
|
|
467
|
+
if (!row)
|
|
468
|
+
return reply.code(404).send({ error: 'not_found' });
|
|
469
|
+
return { activity: await setRecordFavorite(req.user.id, library, shelf, rid, body.favorite) };
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
if (e instanceof UnknownShelfError)
|
|
473
|
+
return reply.code(404).send({ error: 'unknown_shelf' });
|
|
474
|
+
throw e;
|
|
475
|
+
}
|
|
476
|
+
});
|
|
429
477
|
app.get('/api/:library/:shelf/:id', async (req, reply) => {
|
|
430
478
|
const { library, shelf, id } = req.params;
|
|
431
479
|
const rid = Number(id);
|
|
@@ -435,7 +483,11 @@ app.get('/api/:library/:shelf/:id', async (req, reply) => {
|
|
|
435
483
|
const row = await recordGet(library, shelf, rid);
|
|
436
484
|
if (!row)
|
|
437
485
|
return reply.code(404).send({ error: 'not_found' });
|
|
438
|
-
|
|
486
|
+
const masked = maskRecordRow(library, shelf, row);
|
|
487
|
+
if (req.user && tracksRecordActivity(req)) {
|
|
488
|
+
return { ...masked, _activity: await markRecordViewed(req.user.id, library, shelf, rid) };
|
|
489
|
+
}
|
|
490
|
+
return masked;
|
|
439
491
|
}
|
|
440
492
|
catch (e) {
|
|
441
493
|
if (e instanceof UnknownShelfError)
|
package/dist/msg-log.d.ts
CHANGED
|
@@ -8,4 +8,14 @@ export interface MsgLogRow {
|
|
|
8
8
|
tokensOut: number | null;
|
|
9
9
|
ms: number | null;
|
|
10
10
|
}
|
|
11
|
+
export interface MsgLogDiagnosticRow {
|
|
12
|
+
id: number;
|
|
13
|
+
ts: string;
|
|
14
|
+
connector: string;
|
|
15
|
+
role: 'user' | 'assistant';
|
|
16
|
+
tokensIn: number | null;
|
|
17
|
+
tokensOut: number | null;
|
|
18
|
+
ms: number | null;
|
|
19
|
+
}
|
|
11
20
|
export declare function logMessage(row: MsgLogRow): Promise<void>;
|
|
21
|
+
export declare function listRecentMessageMetrics(limit?: number): Promise<MsgLogDiagnosticRow[]>;
|
package/dist/msg-log.js
CHANGED
|
@@ -3,3 +3,19 @@ export async function logMessage(row) {
|
|
|
3
3
|
await getEm().fork().getConnection().execute(`INSERT INTO _orch_msg_log (ts, connector, chat_id, user_id, role, text, tokens_in, tokens_out, ms)
|
|
4
4
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [new Date().toISOString(), row.connector, row.chatId, row.userId, row.role, row.text, row.tokensIn, row.tokensOut, row.ms]);
|
|
5
5
|
}
|
|
6
|
+
export async function listRecentMessageMetrics(limit = 25) {
|
|
7
|
+
const bounded = Math.max(1, Math.min(100, Math.trunc(limit)));
|
|
8
|
+
const rows = (await getEm().fork().getConnection().execute(`SELECT id, ts, connector, role, tokens_in, tokens_out, ms
|
|
9
|
+
FROM _orch_msg_log
|
|
10
|
+
ORDER BY id DESC
|
|
11
|
+
LIMIT ?`, [bounded]));
|
|
12
|
+
return rows.map((r) => ({
|
|
13
|
+
id: Number(r['id']),
|
|
14
|
+
ts: String(r['ts']),
|
|
15
|
+
connector: String(r['connector']),
|
|
16
|
+
role: r['role'] === 'assistant' ? 'assistant' : 'user',
|
|
17
|
+
tokensIn: r['tokens_in'] == null ? null : Number(r['tokens_in']),
|
|
18
|
+
tokensOut: r['tokens_out'] == null ? null : Number(r['tokens_out']),
|
|
19
|
+
ms: r['ms'] == null ? null : Number(r['ms']),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RecordActivityClient {
|
|
2
|
+
favorite: boolean;
|
|
3
|
+
viewCount: number;
|
|
4
|
+
lastViewedAt: string | null;
|
|
5
|
+
score: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function getRecordActivity(userId: number, library: string, shelf: string, recordId: number): Promise<RecordActivityClient>;
|
|
8
|
+
export declare function markRecordViewed(userId: number, library: string, shelf: string, recordId: number, now?: string): Promise<RecordActivityClient>;
|
|
9
|
+
export declare function setRecordFavorite(userId: number, library: string, shelf: string, recordId: number, favorite: boolean): Promise<RecordActivityClient>;
|
|
10
|
+
export declare function deleteRecordActivity(library: string, shelf: string, recordId: number): Promise<void>;
|
|
11
|
+
export declare function applySmartRanking(userId: number, library: string, shelf: string, rows: Record<string, unknown>[], now?: Date): Promise<Record<string, unknown>[]>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { getEm } from "./db.js";
|
|
2
|
+
import { shelfTableName } from "./entity-schema.js";
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const CREATED_HALF_LIFE_DAYS = 30;
|
|
5
|
+
const EDITED_HALF_LIFE_DAYS = 14;
|
|
6
|
+
function activityKey(userId, library, shelf, recordId) {
|
|
7
|
+
return { user_id: userId, library, shelf, record_id: recordId };
|
|
8
|
+
}
|
|
9
|
+
function toClient(row) {
|
|
10
|
+
return {
|
|
11
|
+
favorite: row?.favorite === 1,
|
|
12
|
+
viewCount: Number(row?.view_count ?? 0),
|
|
13
|
+
lastViewedAt: row?.last_viewed_at ?? null,
|
|
14
|
+
score: 0,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function getActivityRow(userId, library, shelf, recordId) {
|
|
18
|
+
const em = getEm().fork();
|
|
19
|
+
return (await em.findOne('_RecordActivity', activityKey(userId, library, shelf, recordId)));
|
|
20
|
+
}
|
|
21
|
+
export async function getRecordActivity(userId, library, shelf, recordId) {
|
|
22
|
+
return toClient((await getActivityRow(userId, library, shelf, recordId)) ?? undefined);
|
|
23
|
+
}
|
|
24
|
+
export async function markRecordViewed(userId, library, shelf, recordId, now = new Date().toISOString()) {
|
|
25
|
+
const em = getEm().fork();
|
|
26
|
+
const key = activityKey(userId, library, shelf, recordId);
|
|
27
|
+
const row = (await em.findOne('_RecordActivity', key));
|
|
28
|
+
if (row) {
|
|
29
|
+
row.view_count = Number(row.view_count ?? 0) + 1;
|
|
30
|
+
row.last_viewed_at = now;
|
|
31
|
+
await em.flush();
|
|
32
|
+
return toClient(row);
|
|
33
|
+
}
|
|
34
|
+
const created = em.create('_RecordActivity', {
|
|
35
|
+
...key,
|
|
36
|
+
favorite: 0,
|
|
37
|
+
view_count: 1,
|
|
38
|
+
last_viewed_at: now,
|
|
39
|
+
});
|
|
40
|
+
await em.flush();
|
|
41
|
+
return toClient(created);
|
|
42
|
+
}
|
|
43
|
+
export async function setRecordFavorite(userId, library, shelf, recordId, favorite) {
|
|
44
|
+
const em = getEm().fork();
|
|
45
|
+
const key = activityKey(userId, library, shelf, recordId);
|
|
46
|
+
const row = (await em.findOne('_RecordActivity', key));
|
|
47
|
+
if (row) {
|
|
48
|
+
row.favorite = favorite ? 1 : 0;
|
|
49
|
+
await em.flush();
|
|
50
|
+
return toClient(row);
|
|
51
|
+
}
|
|
52
|
+
const created = em.create('_RecordActivity', {
|
|
53
|
+
...key,
|
|
54
|
+
favorite: favorite ? 1 : 0,
|
|
55
|
+
view_count: 0,
|
|
56
|
+
last_viewed_at: null,
|
|
57
|
+
});
|
|
58
|
+
await em.flush();
|
|
59
|
+
return toClient(created);
|
|
60
|
+
}
|
|
61
|
+
export async function deleteRecordActivity(library, shelf, recordId) {
|
|
62
|
+
const em = getEm().fork();
|
|
63
|
+
await em.nativeDelete('_RecordActivity', { library, shelf, record_id: recordId });
|
|
64
|
+
}
|
|
65
|
+
async function listActivityRows(userId, library, shelf, ids) {
|
|
66
|
+
if (ids.length === 0)
|
|
67
|
+
return new Map();
|
|
68
|
+
const em = getEm().fork();
|
|
69
|
+
const rows = (await em.find('_RecordActivity', { user_id: userId, library, shelf, record_id: { $in: ids } }));
|
|
70
|
+
return new Map(rows.map((row) => [Number(row.record_id), row]));
|
|
71
|
+
}
|
|
72
|
+
function decayBonus(base, timestamp, halfLifeDays, nowMs) {
|
|
73
|
+
const then = timestamp ? Date.parse(timestamp) : NaN;
|
|
74
|
+
if (!Number.isFinite(then))
|
|
75
|
+
return 0;
|
|
76
|
+
const ageDays = Math.max(0, nowMs - then) / DAY_MS;
|
|
77
|
+
return base * 2 ** (-ageDays / halfLifeDays);
|
|
78
|
+
}
|
|
79
|
+
export async function applySmartRanking(userId, library, shelf, rows, now = new Date()) {
|
|
80
|
+
if (rows.length === 0)
|
|
81
|
+
return rows;
|
|
82
|
+
const ids = rows.map((row) => Number(row.id)).filter((id) => Number.isInteger(id));
|
|
83
|
+
const activities = await listActivityRows(userId, library, shelf, ids);
|
|
84
|
+
const em = getEm().fork();
|
|
85
|
+
const timestamps = (await em.find(shelfTableName(library, shelf), { id: { $in: ids }, deleted_at: null }, { fields: ['id', 'created_at', 'updated_at'] }));
|
|
86
|
+
const dates = new Map(timestamps.map((row) => [Number(row.id), row]));
|
|
87
|
+
const viewed = rows
|
|
88
|
+
.map((row) => ({ id: Number(row.id), last: activities.get(Number(row.id))?.last_viewed_at }))
|
|
89
|
+
.filter((row) => typeof row.last === 'string' && Number.isFinite(Date.parse(row.last)))
|
|
90
|
+
.sort((a, b) => Date.parse(b.last) - Date.parse(a.last));
|
|
91
|
+
const recentReadIds = new Set(viewed.slice(0, Math.max(1, Math.ceil(rows.length * 0.1))).map((row) => row.id));
|
|
92
|
+
const nowMs = now.getTime();
|
|
93
|
+
const decorated = rows.map((row) => {
|
|
94
|
+
const id = Number(row.id);
|
|
95
|
+
const activity = activities.get(id);
|
|
96
|
+
const date = dates.get(id);
|
|
97
|
+
const created = date?.created_at;
|
|
98
|
+
const updated = date?.updated_at;
|
|
99
|
+
const createdBonus = decayBonus(75, created, CREATED_HALF_LIFE_DAYS, nowMs);
|
|
100
|
+
const edited = created && updated && Date.parse(updated) > Date.parse(created) ? updated : null;
|
|
101
|
+
const editedBonus = decayBonus(50, edited, EDITED_HALF_LIFE_DAYS, nowMs);
|
|
102
|
+
const score = (activity?.favorite === 1 ? 100 : 0) + createdBonus + editedBonus + (recentReadIds.has(id) ? 30 : 0);
|
|
103
|
+
const client = {
|
|
104
|
+
favorite: activity?.favorite === 1,
|
|
105
|
+
viewCount: Number(activity?.view_count ?? 0),
|
|
106
|
+
lastViewedAt: activity?.last_viewed_at ?? null,
|
|
107
|
+
score,
|
|
108
|
+
};
|
|
109
|
+
return { row: { ...row, _activity: client }, client, updated: updated ?? '' };
|
|
110
|
+
});
|
|
111
|
+
return decorated
|
|
112
|
+
.sort((a, b) => b.client.score - a.client.score ||
|
|
113
|
+
b.client.viewCount - a.client.viewCount ||
|
|
114
|
+
String(b.client.lastViewedAt ?? '').localeCompare(String(a.client.lastViewedAt ?? '')) ||
|
|
115
|
+
b.updated.localeCompare(a.updated) ||
|
|
116
|
+
Number(b.row.id) - Number(a.row.id))
|
|
117
|
+
.map((item) => item.row);
|
|
118
|
+
}
|
package/dist/records-api.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getEm } from "./db.js";
|
|
|
8
8
|
import { decodeTemporal, dtStringToDate } from "./temporal.js";
|
|
9
9
|
import { splitBody, saveExtends, deleteExtends, validateExtends, readExtends } from "./extend-io.js";
|
|
10
10
|
import { createRecord, updateRecord, getRecord, deleteRecord, restoreRecord, purgeRecord } from "./mutate.js";
|
|
11
|
+
import { deleteRecordActivity } from "./record-activity.js";
|
|
11
12
|
export class UnknownShelfError extends Error {
|
|
12
13
|
}
|
|
13
14
|
export const MAX_PAGE = 500;
|
|
@@ -209,6 +210,7 @@ export async function recordRestore(library, shelf, id, actor = 'gui') {
|
|
|
209
210
|
export async function recordPurge(library, shelf, id, actor = 'gui') {
|
|
210
211
|
const { m, ename } = resolve(library, shelf);
|
|
211
212
|
await purgeRecord(m, ename, id, { actor }, (tx, recordId) => deleteExtends(tx, library, shelf, recordId));
|
|
213
|
+
await deleteRecordActivity(library, shelf, id);
|
|
212
214
|
}
|
|
213
215
|
export async function recordTrashList() {
|
|
214
216
|
const shelves = getActiveRegistry().shelves.filter((s) => s.standalone !== false);
|