ework-web 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getConfigAll } from "./db";
|
|
3
|
+
|
|
4
|
+
export interface TtsBackend {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
url: string;
|
|
8
|
+
voice: string;
|
|
9
|
+
}
|
|
10
|
+
export const DEFAULT_TTS_BACKENDS: TtsBackend[] = [
|
|
11
|
+
{ id: "kokoro", label: "Kokoro(快)", url: "", voice: "zf_001" },
|
|
12
|
+
{ id: "cosyvoice3", label: "CosyVoice3(自然)", url: "", voice: "default" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function parseTtsBackendsJSON(raw: string): TtsBackend[] | null {
|
|
16
|
+
let v: unknown;
|
|
17
|
+
try {
|
|
18
|
+
v = JSON.parse(raw);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (!Array.isArray(v)) return null;
|
|
23
|
+
const out: TtsBackend[] = [];
|
|
24
|
+
for (const item of v) {
|
|
25
|
+
if (typeof item !== "object" || item === null) return null;
|
|
26
|
+
const { id, label, url, voice } = item as Record<string, unknown>;
|
|
27
|
+
if (
|
|
28
|
+
typeof id !== "string" || typeof label !== "string" ||
|
|
29
|
+
typeof url !== "string" || typeof voice !== "string"
|
|
30
|
+
) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
out.push({ id, label, url, voice });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const configSchema = z.object({
|
|
39
|
+
port: z.coerce.number().default(3002),
|
|
40
|
+
host: z.string().default("127.0.0.1"),
|
|
41
|
+
authToken: z.string().min(8, "WORK_TOKEN must be set (>= 8 chars)"),
|
|
42
|
+
cookieSecret: z.string().min(8, "WORK_COOKIE_SECRET must be set (>= 8 chars)"),
|
|
43
|
+
operatorLogin: z
|
|
44
|
+
.string()
|
|
45
|
+
.regex(/^[A-Za-z0-9_-]+$/, "WORK_OPERATOR_LOGIN must be a plain login (alphanumeric)")
|
|
46
|
+
.default("op"),
|
|
47
|
+
systemLogin: z
|
|
48
|
+
.string()
|
|
49
|
+
.regex(/^[A-Za-z0-9_-]+$/, "WORK_SYSTEM_LOGIN must be a plain login (alphanumeric)")
|
|
50
|
+
.default("ework-actions"),
|
|
51
|
+
upstreamTimeoutMs: z.coerce.number().default(15000),
|
|
52
|
+
secureCookie: z.boolean().default(false),
|
|
53
|
+
// ework is human-facing; writes on by default. Flip off to use as a read-only mirror.
|
|
54
|
+
writesEnabled: z.boolean().default(true),
|
|
55
|
+
// Issue-thread comment order: 'desc' = newest first (top), 'asc' = oldest first.
|
|
56
|
+
// Drives both SSR initial render and app.js display order.
|
|
57
|
+
commentSort: z.enum(["desc", "asc"]).default("desc"),
|
|
58
|
+
accessLogPath: z.string().default("/tmp/ework-access.log"),
|
|
59
|
+
opencodeBin: z.string().default("opencode"),
|
|
60
|
+
opencodeDbPath: z.string().default(
|
|
61
|
+
`${process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`}/opencode/opencode.db`
|
|
62
|
+
),
|
|
63
|
+
contextBudget: z.coerce.number().int().positive().default(200000),
|
|
64
|
+
collapseLines: z.coerce.number().int().positive().default(16),
|
|
65
|
+
fileRoots: z
|
|
66
|
+
.string()
|
|
67
|
+
.transform((s) => s.split(",").map((x) => x.trim()).filter(Boolean))
|
|
68
|
+
.default("/tmp"),
|
|
69
|
+
fileMaxLines: z.coerce.number().int().positive().default(2000),
|
|
70
|
+
fileMaxBytes: z.coerce.number().int().positive().default(512 * 1024),
|
|
71
|
+
translateUrl: z.string().default(""),
|
|
72
|
+
translateModel: z.string().default("qwen2.5-7b"),
|
|
73
|
+
ttsBackends: z
|
|
74
|
+
.array(z.object({ id: z.string(), label: z.string(), url: z.string(), voice: z.string() }))
|
|
75
|
+
.default(DEFAULT_TTS_BACKENDS),
|
|
76
|
+
ttsDefaultBackend: z.string().default("kokoro"),
|
|
77
|
+
ttsSpeed: z.coerce.number().min(0.25).max(4).default(1.0),
|
|
78
|
+
daemonBotLogin: z.string().default(""),
|
|
79
|
+
daemonWebhookUrl: z.string().default(""),
|
|
80
|
+
daemonWebhookSecret: z.string().default(""),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export type Config = z.infer<typeof configSchema>;
|
|
84
|
+
|
|
85
|
+
export type FieldType = "text" | "number" | "backend";
|
|
86
|
+
export interface SettingField {
|
|
87
|
+
key: keyof Config;
|
|
88
|
+
label: string;
|
|
89
|
+
type: FieldType;
|
|
90
|
+
}
|
|
91
|
+
export interface SettingGroup {
|
|
92
|
+
title: string;
|
|
93
|
+
fields: SettingField[];
|
|
94
|
+
}
|
|
95
|
+
export const SETTINGS_GROUPS: SettingGroup[] = [
|
|
96
|
+
{
|
|
97
|
+
title: "翻译",
|
|
98
|
+
fields: [
|
|
99
|
+
{ key: "translateUrl", label: "API 地址", type: "text" },
|
|
100
|
+
{ key: "translateModel", label: "模型", type: "text" },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
title: "朗读 (TTS)",
|
|
105
|
+
fields: [
|
|
106
|
+
{ key: "ttsDefaultBackend", label: "默认引擎", type: "backend" },
|
|
107
|
+
{ key: "ttsSpeed", label: "语速 (0.25–4)", type: "number" },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
title: "会话显示",
|
|
112
|
+
fields: [
|
|
113
|
+
{ key: "contextBudget", label: "上下文窗口 (tokens)", type: "number" },
|
|
114
|
+
{ key: "collapseLines", label: "折叠阈值 (行)", type: "number" },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
title: "Issue 评论排序",
|
|
119
|
+
fields: [
|
|
120
|
+
{ key: "commentSort", label: "默认顺序(desc=最新在上 / asc=最老在上)", type: "text" },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
title: "文件查看",
|
|
125
|
+
fields: [
|
|
126
|
+
{ key: "fileRoots", label: "可浏览根目录(逗号分隔)", type: "text" },
|
|
127
|
+
{ key: "fileMaxLines", label: "最大行数", type: "number" },
|
|
128
|
+
{ key: "fileMaxBytes", label: "最大字节", type: "number" },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
export const DB_OVERRIDABLE: (keyof Config)[] = SETTINGS_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
|
|
133
|
+
|
|
134
|
+
export function loadConfig(): Config {
|
|
135
|
+
const db = getConfigAll();
|
|
136
|
+
return configSchema.parse({
|
|
137
|
+
port: process.env.WORK_PORT,
|
|
138
|
+
host: process.env.WORK_HOST,
|
|
139
|
+
authToken: process.env.WORK_TOKEN,
|
|
140
|
+
cookieSecret: process.env.WORK_COOKIE_SECRET,
|
|
141
|
+
operatorLogin: process.env.WORK_OPERATOR_LOGIN,
|
|
142
|
+
systemLogin: process.env.WORK_SYSTEM_LOGIN,
|
|
143
|
+
upstreamTimeoutMs: process.env.WORK_UPSTREAM_TIMEOUT_MS,
|
|
144
|
+
secureCookie: process.env.WORK_SECURE_COOKIE === "true",
|
|
145
|
+
writesEnabled: (process.env.WORK_WRITES_ENABLED ?? "true") === "true",
|
|
146
|
+
commentSort: (db.commentSort as "desc" | "asc" | undefined) ?? process.env.WORK_COMMENT_SORT,
|
|
147
|
+
accessLogPath: process.env.WORK_ACCESS_LOG ?? "/tmp/ework-access.log",
|
|
148
|
+
opencodeBin: process.env.WORK_OPENCODE_BIN,
|
|
149
|
+
opencodeDbPath: process.env.WORK_OPENCODE_DB,
|
|
150
|
+
contextBudget: db.contextBudget ?? process.env.WORK_CONTEXT_BUDGET,
|
|
151
|
+
collapseLines: db.collapseLines ?? process.env.WORK_COLLAPSE_LINES,
|
|
152
|
+
fileRoots: db.fileRoots ?? process.env.WORK_FILE_ROOTS,
|
|
153
|
+
fileMaxLines: db.fileMaxLines ?? process.env.WORK_FILE_MAX_LINES,
|
|
154
|
+
fileMaxBytes: db.fileMaxBytes ?? process.env.WORK_FILE_MAX_BYTES,
|
|
155
|
+
translateUrl: db.translateUrl ?? process.env.WORK_TRANSLATE_URL,
|
|
156
|
+
translateModel: db.translateModel ?? process.env.WORK_TRANSLATE_MODEL,
|
|
157
|
+
ttsBackends: (db.ttsBackends && parseTtsBackendsJSON(db.ttsBackends)) ?? DEFAULT_TTS_BACKENDS,
|
|
158
|
+
ttsDefaultBackend: db.ttsDefaultBackend ?? process.env.WORK_TTS_DEFAULT_BACKEND,
|
|
159
|
+
ttsSpeed: db.ttsSpeed ?? process.env.WORK_TTS_SPEED,
|
|
160
|
+
daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "",
|
|
161
|
+
daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
|
|
162
|
+
daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function resolveTtsBackend(cfg: Config, id?: string): TtsBackend | null {
|
|
167
|
+
const list = cfg.ttsBackends;
|
|
168
|
+
if (list.length === 0) return null;
|
|
169
|
+
return list.find((b) => b.id === id) ?? list.find((b) => b.id === cfg.ttsDefaultBackend) ?? list[0] ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function parseOverride(key: keyof Config, raw: string): unknown | null {
|
|
173
|
+
const shape = configSchema.shape as Record<string, z.ZodTypeAny>;
|
|
174
|
+
const field = shape[key as string];
|
|
175
|
+
if (!field) return null;
|
|
176
|
+
const r = field.safeParse(raw);
|
|
177
|
+
return r.success ? r.data : null;
|
|
178
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SQLite store. One DB file holds the runtime config table AND the ework schema
|
|
2
|
+
// (projects/issues/comments/labels/reactions/attachments/users) so transactions
|
|
3
|
+
// span them. Schema is applied on boot via schema.sql.
|
|
4
|
+
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdirSync, readFileSync } from "fs";
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
|
|
9
|
+
const DB_PATH =
|
|
10
|
+
process.env.WORK_DB_PATH ||
|
|
11
|
+
join(process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`, "ework", "ework.db");
|
|
12
|
+
|
|
13
|
+
let _db: Database | null = null;
|
|
14
|
+
|
|
15
|
+
function userTableColumns(db: Database): Set<string> {
|
|
16
|
+
const rows = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
|
17
|
+
return new Set(rows.map((r) => r.name));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// schema.sql CREATE TABLE only applies full column list to fresh DBs;
|
|
21
|
+
// existing DBs need these ALTERs to gain new columns. Idempotent via PRAGMA check.
|
|
22
|
+
function migrateUsersTable(db: Database): void {
|
|
23
|
+
if (userTableColumns(db).size === 0) return; // users doesn't exist yet; schema.sql will create it
|
|
24
|
+
const have = userTableColumns(db);
|
|
25
|
+
const additions: { col: string; ddl: string }[] = [
|
|
26
|
+
{ col: "password_hash", ddl: "ALTER TABLE users ADD COLUMN password_hash TEXT" },
|
|
27
|
+
{ col: "email", ddl: "ALTER TABLE users ADD COLUMN email TEXT" },
|
|
28
|
+
{ col: "is_admin", ddl: "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0" },
|
|
29
|
+
{ col: "is_active", ddl: "ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1" },
|
|
30
|
+
{ col: "updated_at", ddl: "ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''" },
|
|
31
|
+
];
|
|
32
|
+
for (const m of additions) {
|
|
33
|
+
if (have.has(m.col)) continue;
|
|
34
|
+
db.exec(m.ddl);
|
|
35
|
+
}
|
|
36
|
+
db.exec("CREATE INDEX IF NOT EXISTS users_is_admin ON users (is_admin) WHERE is_admin = 1");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tableColumns(db: Database, name: string): Set<string> {
|
|
40
|
+
const rows = db.query(`PRAGMA table_info(${name})`).all() as { name: string }[];
|
|
41
|
+
return new Set(rows.map((r) => r.name));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function migratePatTable(db: Database): void {
|
|
45
|
+
const have = tableColumns(db, "personal_access_tokens");
|
|
46
|
+
if (have.size === 0) return;
|
|
47
|
+
if (!have.has("ip_allowlist")) {
|
|
48
|
+
db.exec("ALTER TABLE personal_access_tokens ADD COLUMN ip_allowlist TEXT NOT NULL DEFAULT '[]'");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function migrateProjectsTable(db: Database): void {
|
|
53
|
+
const have = tableColumns(db, "projects");
|
|
54
|
+
if (have.size === 0) return;
|
|
55
|
+
if (!have.has("upstream_urls")) {
|
|
56
|
+
db.exec("ALTER TABLE projects ADD COLUMN upstream_urls TEXT NOT NULL DEFAULT '[]'");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function db(): Database {
|
|
61
|
+
if (_db) return _db;
|
|
62
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
63
|
+
_db = new Database(DB_PATH, { create: true, readwrite: true });
|
|
64
|
+
_db.exec("PRAGMA journal_mode = WAL");
|
|
65
|
+
_db.exec("PRAGMA foreign_keys = ON");
|
|
66
|
+
_db.exec(
|
|
67
|
+
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
|
68
|
+
);
|
|
69
|
+
// Migration must run BEFORE schema.sql: schema.sql's CREATE TABLE for fresh
|
|
70
|
+
// DBs doesn't help legacy DBs, and any index that references a new column
|
|
71
|
+
// would fail if schema.sql ran first.
|
|
72
|
+
migrateUsersTable(_db);
|
|
73
|
+
migratePatTable(_db);
|
|
74
|
+
migrateProjectsTable(_db);
|
|
75
|
+
_db.exec(readFileSync(join(import.meta.dir, "schema.sql"), "utf8"));
|
|
76
|
+
return _db;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function rawDB(): Database {
|
|
80
|
+
return db();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getConfigAll(): Record<string, string> {
|
|
84
|
+
try {
|
|
85
|
+
const rows = db().query("SELECT key, value FROM config").all() as { key: string; value: string }[];
|
|
86
|
+
const out: Record<string, string> = {};
|
|
87
|
+
for (const r of rows) out[r.key] = r.value;
|
|
88
|
+
return out;
|
|
89
|
+
} catch {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setConfig(key: string, value: string): void {
|
|
95
|
+
const now = new Date().toISOString();
|
|
96
|
+
db()
|
|
97
|
+
.query(
|
|
98
|
+
"INSERT INTO config (key, value, updated_at) VALUES (?, ?, ?) " +
|
|
99
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
|
|
100
|
+
)
|
|
101
|
+
.run(key, value, now);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function deleteConfig(key: string): void {
|
|
105
|
+
db().query("DELETE FROM config WHERE key = ?").run(key);
|
|
106
|
+
}
|