ework-web 0.10.15 → 0.10.17
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/package.json +1 -1
- package/src/config.ts +1 -1
- package/src/db-admin.ts +69 -0
- package/src/index.ts +30 -5
- package/src/static/db-wizard.js +54 -1
- package/src/views/settings.ts +14 -10
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -107,7 +107,7 @@ export const SETTINGS_GROUPS: SettingGroup[] = [
|
|
|
107
107
|
{
|
|
108
108
|
title: "AI 模型",
|
|
109
109
|
fields: [
|
|
110
|
-
{ key: "defaultModel", label: "
|
|
110
|
+
{ key: "defaultModel", label: "默认模型(点下方「刷新 opencode 模型列表」更新)", type: "model" },
|
|
111
111
|
],
|
|
112
112
|
},
|
|
113
113
|
{
|
package/src/db-admin.ts
CHANGED
|
@@ -575,6 +575,75 @@ const DAEMON_DDL: Record<string, string> = {
|
|
|
575
575
|
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
576
576
|
};
|
|
577
577
|
|
|
578
|
+
export interface DdlResult {
|
|
579
|
+
ok: true;
|
|
580
|
+
sql: string;
|
|
581
|
+
database: string;
|
|
582
|
+
webPrefix: string;
|
|
583
|
+
daemonPrefix: string;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Generate a runnable MySQL script for accounts whose DB user lacks CREATE
|
|
587
|
+
* (the wizard's auto-CREATE-DATABASE path is blocked). Reuses migrate()'s
|
|
588
|
+
* exact DDL so manual + auto paths stay in sync. Idempotent. */
|
|
589
|
+
export function generateMysqlDDL(
|
|
590
|
+
opts: MysqlTargetOpts,
|
|
591
|
+
daemonPrefixRaw: string,
|
|
592
|
+
): DdlResult {
|
|
593
|
+
const webPrefix = validatePrefix(opts.prefix ?? "");
|
|
594
|
+
const daemonPrefix = validatePrefix(daemonPrefixRaw);
|
|
595
|
+
const lines: string[] = [];
|
|
596
|
+
|
|
597
|
+
lines.push(`-- ework-web + ework-daemon MySQL schema`);
|
|
598
|
+
lines.push(`-- Generated for restricted-privilege accounts (no CREATE).`);
|
|
599
|
+
lines.push(`-- Database: ${opts.database} | web prefix: "${webPrefix}" | daemon prefix: "${daemonPrefix}"`);
|
|
600
|
+
lines.push(`-- Idempotent — safe to re-run.`);
|
|
601
|
+
lines.push(``);
|
|
602
|
+
lines.push(`CREATE DATABASE IF NOT EXISTS ${ident(opts.database)} CHARACTER SET utf8mb4;`);
|
|
603
|
+
lines.push(`USE ${ident(opts.database)};`);
|
|
604
|
+
lines.push(``);
|
|
605
|
+
lines.push(`-- ─── ework-web tables (${webPrefix ? `prefix "${webPrefix}"` : "no prefix"}) ───`);
|
|
606
|
+
|
|
607
|
+
for (const stmt of readSchemaMysqlStatements(webPrefix)) {
|
|
608
|
+
lines.push(stmt + ";");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// config table — created in db.ts:SqliteDriver.create at boot, missing from
|
|
612
|
+
// schema-mysql.sql. Same shape as migrateSqliteToMysql L257-266. `key` is a
|
|
613
|
+
// MySQL reserved word → backticked.
|
|
614
|
+
lines.push(
|
|
615
|
+
applyTargetPrefix(
|
|
616
|
+
"CREATE TABLE IF NOT EXISTS {{config}} (" +
|
|
617
|
+
"`key` VARCHAR(255) PRIMARY KEY," +
|
|
618
|
+
"value TEXT NOT NULL," +
|
|
619
|
+
"updated_at VARCHAR(40) NOT NULL" +
|
|
620
|
+
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
|
621
|
+
webPrefix
|
|
622
|
+
)
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
lines.push(``);
|
|
626
|
+
lines.push(`-- ─── ework-daemon tables (${daemonPrefix ? `prefix "${daemonPrefix}"` : "no prefix"}) ───`);
|
|
627
|
+
lines.push(`-- Daemon columns match its SQLite schema; coordination columns`);
|
|
628
|
+
lines.push(`-- (owner_daemon_id etc.) are added by the daemon's ALTER TABLE on boot.`);
|
|
629
|
+
for (const table of DAEMON_TABLES) {
|
|
630
|
+
const ddl = applyTargetPrefix(
|
|
631
|
+
DAEMON_DDL[table]!.replace(/{{t}}/g, `{{${table}}}`),
|
|
632
|
+
daemonPrefix
|
|
633
|
+
);
|
|
634
|
+
lines.push(ddl + ";");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
lines.push(``);
|
|
638
|
+
return {
|
|
639
|
+
ok: true,
|
|
640
|
+
sql: lines.join("\n"),
|
|
641
|
+
database: opts.database,
|
|
642
|
+
webPrefix,
|
|
643
|
+
daemonPrefix,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
578
647
|
export async function migrateDaemonSqliteToMysql(
|
|
579
648
|
sqlitePath: string,
|
|
580
649
|
opts: MysqlTargetOpts,
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./
|
|
|
7
7
|
import type { Config } from "./config";
|
|
8
8
|
import { setConfig, initDB, getDB } from "./db";
|
|
9
9
|
import { getActiveDaemons, listAllDaemons } from "./coordination";
|
|
10
|
-
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql } from "./db-admin";
|
|
10
|
+
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
|
|
11
11
|
import type { MysqlTargetOpts } from "./db-admin";
|
|
12
12
|
import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
|
|
13
13
|
import { OpencodeError, createOpencodeClient, MultiDaemonOpencodeClient, RemoteOpencodeClient, isLocalhost, type OpencodeClientInterface } from "./opencode";
|
|
@@ -201,7 +201,9 @@ function html(body: string, status = 200): Response {
|
|
|
201
201
|
// The prefix regex below must match db.ts:WORK_DB_PREFIX exactly — if the API
|
|
202
202
|
// accepts a prefix that boot rejects, the wizard writes a .env that restarts
|
|
203
203
|
// into a crash. Any change here must be mirrored in db.ts + db-admin.ts.
|
|
204
|
-
|
|
204
|
+
type ParsedDbTarget = MysqlTargetOpts & { daemonPrefix?: string };
|
|
205
|
+
|
|
206
|
+
function parseDbTargetOpts(payload: unknown): ParsedDbTarget | { error: string } {
|
|
205
207
|
if (typeof payload !== "object" || payload === null) return { error: "invalid body" };
|
|
206
208
|
const p = payload as Record<string, unknown>;
|
|
207
209
|
const host = typeof p.host === "string" ? p.host.trim() : "";
|
|
@@ -210,17 +212,23 @@ function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string
|
|
|
210
212
|
const password = typeof p.password === "string" ? p.password : "";
|
|
211
213
|
const database = typeof p.database === "string" ? p.database.trim() : "";
|
|
212
214
|
const prefix = typeof p.prefix === "string" ? p.prefix.trim() : "";
|
|
215
|
+
const daemonPrefix = typeof p.daemonPrefix === "string" ? p.daemonPrefix.trim() : "";
|
|
213
216
|
if (!host) return { error: "host required" };
|
|
214
217
|
if (!user) return { error: "user required" };
|
|
215
218
|
if (!database) return { error: "database required" };
|
|
216
219
|
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
|
217
220
|
return { error: "port must be an integer 1-65535" };
|
|
218
221
|
}
|
|
219
|
-
|
|
222
|
+
const prefixRe = /^[A-Za-z_][A-Za-z0-9_]{0,31}$/;
|
|
223
|
+
if (prefix && !prefixRe.test(prefix)) {
|
|
220
224
|
return { error: "prefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
|
|
221
225
|
}
|
|
222
|
-
|
|
226
|
+
if (daemonPrefix && !prefixRe.test(daemonPrefix)) {
|
|
227
|
+
return { error: "daemonPrefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
|
|
228
|
+
}
|
|
229
|
+
const opts: ParsedDbTarget = { host, port: portNum, user, password, database };
|
|
223
230
|
if (prefix) opts.prefix = prefix;
|
|
231
|
+
if (daemonPrefix) opts.daemonPrefix = daemonPrefix;
|
|
224
232
|
return opts;
|
|
225
233
|
}
|
|
226
234
|
|
|
@@ -805,7 +813,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
805
813
|
const rawPayload = await req.json().catch(() => ({}));
|
|
806
814
|
const parsed = parseDbTargetOpts(rawPayload);
|
|
807
815
|
if ("error" in parsed) return json(parsed, 400);
|
|
808
|
-
const
|
|
816
|
+
const { daemonPrefix: _dp, ...mysqlFields } = parsed;
|
|
817
|
+
const dPrefix = parsed.daemonPrefix ?? daemonPrefix(parsed.prefix ?? "");
|
|
818
|
+
const daemonOpts: MysqlTargetOpts = { ...mysqlFields, prefix: dPrefix };
|
|
809
819
|
const envPath = daemonEnvPath();
|
|
810
820
|
if (!envPath) {
|
|
811
821
|
return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
|
|
@@ -825,6 +835,21 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
825
835
|
}
|
|
826
836
|
}
|
|
827
837
|
|
|
838
|
+
if (req.method === "POST" && url.pathname === "/api/db/ddl") {
|
|
839
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
840
|
+
if (!rateLimit(`db-ddl:${ip}`, 10, 10 / 60)) return json({ error: "rate limited" }, 429);
|
|
841
|
+
const payload = await req.json().catch(() => ({}));
|
|
842
|
+
const parsed = parseDbTargetOpts(payload);
|
|
843
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
844
|
+
const dPrefix = parsed.daemonPrefix ?? daemonPrefix(parsed.prefix ?? "");
|
|
845
|
+
try {
|
|
846
|
+
const result = generateMysqlDDL(parsed, dPrefix);
|
|
847
|
+
return json(result);
|
|
848
|
+
} catch (e) {
|
|
849
|
+
return json({ ok: false, error: errMsg(e) }, 400);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
828
853
|
if (req.method === "POST" && url.pathname === "/api/db/revert") {
|
|
829
854
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
830
855
|
if (!rateLimit(`db-revert:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
|
package/src/static/db-wizard.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
user: get('db-user').trim(),
|
|
13
13
|
password: get('db-password'),
|
|
14
14
|
database: get('db-database').trim(),
|
|
15
|
-
prefix: get('db-prefix').trim()
|
|
15
|
+
prefix: get('db-prefix').trim(),
|
|
16
|
+
daemonPrefix: get('db-daemon-prefix').trim()
|
|
16
17
|
});
|
|
17
18
|
}
|
|
18
19
|
function setBtns(d) {
|
|
@@ -74,6 +75,58 @@
|
|
|
74
75
|
document.getElementById('db-daemon').onclick = function () { act('daemon-config'); };
|
|
75
76
|
document.getElementById('db-enable').onclick = function () { act('enable'); };
|
|
76
77
|
|
|
78
|
+
var ddlBtn = document.getElementById('db-ddl');
|
|
79
|
+
if (ddlBtn) {
|
|
80
|
+
ddlBtn.onclick = async function () {
|
|
81
|
+
setBtns(true);
|
|
82
|
+
R.className = 'db-result db-loading';
|
|
83
|
+
R.textContent = '生成建表 SQL…';
|
|
84
|
+
try {
|
|
85
|
+
var res = await fetch('/api/db/ddl', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: gather()
|
|
89
|
+
});
|
|
90
|
+
var data = await res.json();
|
|
91
|
+
if (data.ok) {
|
|
92
|
+
R.className = 'db-result db-ok';
|
|
93
|
+
R.textContent = '✓ SQL 已生成(' + data.database + ' · web 前缀 "' + (data.webPrefix || '(无)') + '" · daemon 前缀 "' + (data.daemonPrefix || '(无)') + '")。复制下方 SQL,用有 CREATE 权限的账号在 MySQL 里跑一遍,再回来点 ② 迁移。';
|
|
94
|
+
var out = document.getElementById('db-ddl-out');
|
|
95
|
+
var ta = document.getElementById('db-ddl-text');
|
|
96
|
+
var meta = document.getElementById('db-ddl-meta');
|
|
97
|
+
ta.value = data.sql;
|
|
98
|
+
meta.textContent = data.sql.length + ' 字符';
|
|
99
|
+
out.style.display = '';
|
|
100
|
+
} else {
|
|
101
|
+
R.className = 'db-result db-err';
|
|
102
|
+
R.textContent = '✗ ' + (data.error || '未知错误');
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
R.className = 'db-result db-err';
|
|
106
|
+
R.textContent = '✗ ' + (e.message || String(e));
|
|
107
|
+
} finally {
|
|
108
|
+
setBtns(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
var ddlCopy = document.getElementById('db-ddl-copy');
|
|
114
|
+
if (ddlCopy) {
|
|
115
|
+
ddlCopy.onclick = async function () {
|
|
116
|
+
var ta = document.getElementById('db-ddl-text');
|
|
117
|
+
try {
|
|
118
|
+
await navigator.clipboard.writeText(ta.value);
|
|
119
|
+
ddlCopy.textContent = '✓ 已复制';
|
|
120
|
+
setTimeout(function () { ddlCopy.textContent = '📋 复制 SQL'; }, 1500);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
ta.select();
|
|
123
|
+
document.execCommand('copy');
|
|
124
|
+
ddlCopy.textContent = '✓ 已复制';
|
|
125
|
+
setTimeout(function () { ddlCopy.textContent = '📋 复制 SQL'; }, 1500);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
77
130
|
var revertBtn = document.getElementById('db-revert');
|
|
78
131
|
if (revertBtn) {
|
|
79
132
|
revertBtn.onclick = async function () {
|
package/src/views/settings.ts
CHANGED
|
@@ -20,14 +20,15 @@ function fieldInput(group: SettingGroup, cfg: Config, models: CachedModel[]): st
|
|
|
20
20
|
// If the cache is empty (opencode not yet polled), fall back to a free
|
|
21
21
|
// text input so the user can still type a known provider/model id.
|
|
22
22
|
if (models.length === 0) {
|
|
23
|
-
return `<label class="sf"><span>${escapeHtml(f.label)}</span><input type="text" name="${escapeAttr(String(f.key))}" value="${escapeAttr(cur)}" placeholder="provider/model
|
|
23
|
+
return `<label class="sf"><span>${escapeHtml(f.label)}</span><input type="text" name="${escapeAttr(String(f.key))}" value="${escapeAttr(cur)}" placeholder="provider/model(点下方刷新拉列表)"></label>`;
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
25
|
+
// No empty "use opencode default" option: empty default lets env
|
|
26
|
+
// vars pollute the model slot (see M-1 in index.ts refresh handler).
|
|
27
|
+
// If cur is empty, the browser auto-selects the first option.
|
|
28
|
+
const opts = models
|
|
29
|
+
.map(
|
|
30
|
+
(m) =>
|
|
31
|
+
`<option value="${escapeAttr(m.id)}"${m.id === cur ? " selected" : ""}>${escapeHtml(m.label)}</option>`
|
|
31
32
|
)
|
|
32
33
|
.join("");
|
|
33
34
|
return `<label class="sf"><span>${escapeHtml(f.label)}</span><select name="${escapeAttr(String(f.key))}">${opts}</select></label>`;
|
|
@@ -48,20 +49,23 @@ function buildDbSection(viewer: UserRow): string {
|
|
|
48
49
|
return `<section class="sg db-section">
|
|
49
50
|
<h2>数据库后端</h2>
|
|
50
51
|
<p class="db-badge">当前后端: <strong>${escapeHtml(driver.toUpperCase())}</strong> · ${escapeHtml(currentTarget)}</p>
|
|
51
|
-
<div class="db-warn">⚠ 切换到 MySQL 后 Web 进程会重启并以 MySQL 为存储。若 MySQL 不可达,进程无法启动——需手动编辑 <code>.env</code> 将 <code>WORK_DB_DRIVER</code> 改回 <code>sqlite</code> 才能恢复。流程:先 ① 测试连接、再 ② 迁移数据、最后 ③
|
|
52
|
+
<div class="db-warn">⚠ 切换到 MySQL 后 Web 进程会重启并以 MySQL 为存储。若 MySQL 不可达,进程无法启动——需手动编辑 <code>.env</code> 将 <code>WORK_DB_DRIVER</code> 改回 <code>sqlite</code> 才能恢复。流程:先 ① 测试连接、再 ② 迁移数据、最后 ③ 启用。若你的 MySQL 账号无 CREATE 权限,先点 ⑤ 生成建表 SQL,用有权限的账号跑一遍,再回来 ② 迁移。</div>
|
|
52
53
|
<label class="sf"><span>MySQL 主机</span><input type="text" id="db-host" placeholder="127.0.0.1" autocomplete="off"></label>
|
|
53
54
|
<label class="sf"><span>端口</span><input type="number" id="db-port" value="3306" min="1" max="65535" autocomplete="off"></label>
|
|
54
55
|
<label class="sf"><span>用户名</span><input type="text" id="db-user" placeholder="ework" autocomplete="off"></label>
|
|
55
56
|
<label class="sf"><span>密码</span><input type="password" id="db-password" placeholder="••••••" autocomplete="new-password"></label>
|
|
56
57
|
<label class="sf"><span>数据库名</span><input type="text" id="db-database" placeholder="ework" autocomplete="off"></label>
|
|
57
58
|
<label class="sf"><span>表前缀(可选)</span><input type="text" id="db-prefix" placeholder="留空 = 无前缀" autocomplete="off"></label>
|
|
59
|
+
<label class="sf"><span>daemon 前缀(可选)</span><input type="text" id="db-daemon-prefix" placeholder="留空 = 自动用「表前缀」+d_" autocomplete="off"></label>
|
|
58
60
|
<div class="db-controls">
|
|
59
61
|
<button type="button" id="db-test">① 测试连接</button>
|
|
60
62
|
<button type="button" id="db-migrate" class="secondary">② 迁移数据</button>
|
|
61
63
|
<button type="button" id="db-daemon" class="secondary">③ 配置 daemon</button>
|
|
62
64
|
<button type="button" id="db-enable" class="secondary">④ 启用并重启</button>
|
|
65
|
+
<button type="button" id="db-ddl" class="secondary">⑤ 生成建表 SQL(手动建库)</button>
|
|
63
66
|
</div>
|
|
64
67
|
<div id="db-result" class="db-result"></div>
|
|
68
|
+
<div id="db-ddl-out" style="display:none;margin-top:.6rem"><div class="db-controls" style="align-items:center"><button type="button" id="db-ddl-copy" class="secondary">📋 复制 SQL</button><span id="db-ddl-meta" style="font-size:12px;color:var(--text-muted)"></span></div><textarea id="db-ddl-text" readonly style="width:100%;min-height:240px;padding:.5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" wrap="off"></textarea></div>
|
|
65
69
|
${isMysql ? `<hr style="border:0;border-top:1px solid var(--border);margin:1rem 0"><div class="db-controls"><button type="button" id="db-revert" class="secondary">⚠ 切回 SQLite(安全网)</button></div><div id="db-revert-result" class="db-result"></div>` : ""}
|
|
66
70
|
<script src="/static/db-wizard.js"></script>
|
|
67
71
|
</section>`;
|
|
@@ -104,7 +108,7 @@ export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow,
|
|
|
104
108
|
const ttsLink = viewer.is_admin === 1
|
|
105
109
|
? `<p class="hint">要增删朗读后端(kokoro / cosyvoice3 等),去 <a href="/admin/tts-backends">朗读后端管理</a>。</p>`
|
|
106
110
|
: "";
|
|
107
|
-
const modelRefreshForm = `<form method="POST" action="/settings/models/refresh"
|
|
111
|
+
const modelRefreshForm = `<section class="sg"><h2>opencode 模型列表</h2><p class="hint" style="margin:0 0 .6rem">从 <code>opencode models</code> 拉取可用模型并刷新上方下拉列表。刷新后自动选定一个具体模型作为默认(不会留空)。</p><form method="POST" action="/settings/models/refresh"><button type="submit" class="secondary">🔄 刷新 opencode 模型列表</button></form></section>`;
|
|
108
112
|
const html = `<!doctype html>
|
|
109
113
|
<html lang="zh"><head><meta charset="utf-8">
|
|
110
114
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -164,9 +168,9 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
|
|
|
164
168
|
<p class="hint">改完保存立即生效,无需重启。密钥/启动项(token、端口等)仍在 <code>.env</code>,不在此处。</p>
|
|
165
169
|
${banner}
|
|
166
170
|
<form method="POST" action="/settings">${groups}
|
|
167
|
-
${modelRefreshForm}
|
|
168
171
|
<div class="bar"><button type="submit">保存</button><a class="a-back" href="/">返回</a></div>
|
|
169
172
|
</form>
|
|
173
|
+
${modelRefreshForm}
|
|
170
174
|
${ttsLink}
|
|
171
175
|
${buildDbSection(viewer)}
|
|
172
176
|
${buildDaemonSection(viewer)}
|