ework-web 0.10.86 → 0.10.88
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/db.ts +4 -0
- package/src/index.ts +33 -3
- package/src/pi-sessions.ts +222 -60
- package/src/render/layout.ts +9 -1
- package/src/schema-mysql.sql +2 -1
- package/src/schema.sql +4 -1
- package/src/static/issue-actions.js +33 -0
- package/src/store.ts +9 -2
- package/src/views/issueNew.ts +5 -0
- package/src/views/issueThread.ts +1 -0
- package/src/webhooks.ts +11 -4
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -134,6 +134,9 @@ function migrateIssuesTable(db: Database): void {
|
|
|
134
134
|
if (!have.has("model")) {
|
|
135
135
|
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
|
|
136
136
|
}
|
|
137
|
+
if (!have.has("runtime")) {
|
|
138
|
+
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN runtime TEXT NOT NULL DEFAULT ''"));
|
|
139
|
+
}
|
|
137
140
|
if (!have.has("upstream_issue_number")) {
|
|
138
141
|
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN upstream_issue_number INTEGER"));
|
|
139
142
|
}
|
|
@@ -391,6 +394,7 @@ async function migrateMysqlColumn(pool: Pool, table: string, column: string, ddl
|
|
|
391
394
|
async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
|
|
392
395
|
await migrateMysqlColumn(pool, "issues", "ai_status", "ai_status VARCHAR(32) NOT NULL DEFAULT ''");
|
|
393
396
|
await migrateMysqlColumn(pool, "issues", "model", "model VARCHAR(128) NOT NULL DEFAULT ''");
|
|
397
|
+
await migrateMysqlColumn(pool, "issues", "runtime", "runtime VARCHAR(32) NOT NULL DEFAULT ''");
|
|
394
398
|
await migrateMysqlColumn(pool, "issues", "upstream_issue_number", "upstream_issue_number INT DEFAULT NULL");
|
|
395
399
|
await migrateMysqlColumn(pool, "comments", "model", "model VARCHAR(128) NOT NULL DEFAULT ''");
|
|
396
400
|
await migrateMysqlColumn(pool, "comments", "upstream_comment_id", "upstream_comment_id BIGINT DEFAULT NULL");
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
setIssueState,
|
|
41
41
|
updateIssueAiStatus,
|
|
42
42
|
updateIssueModel,
|
|
43
|
+
updateIssueRuntime,
|
|
43
44
|
listIssues,
|
|
44
45
|
createAttachment,
|
|
45
46
|
getAttachment,
|
|
@@ -398,6 +399,7 @@ const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
|
398
399
|
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
399
400
|
const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on)$/;
|
|
400
401
|
const REPO_ISSUE_MODEL_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/model$/;
|
|
402
|
+
const REPO_ISSUE_RUNTIME_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/runtime$/;
|
|
401
403
|
const REPO_ISSUE_STATUS_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/ai-status$/;
|
|
402
404
|
const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
|
|
403
405
|
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
|
|
@@ -726,12 +728,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
726
728
|
return html(errorPage("等待启动", "会话已创建,后端会话 ID 尚未生成(正在准备工作目录)。稍后刷新此页。"), 200);
|
|
727
729
|
}
|
|
728
730
|
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
729
|
-
const
|
|
731
|
+
const piAsc = url.searchParams.get("asc") === "1";
|
|
732
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
730
733
|
if (piPage) return html(piPage, 200);
|
|
731
734
|
} else {
|
|
732
735
|
// non-ses_ id that resolved to nothing: try the pi session file before 404
|
|
733
736
|
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
734
|
-
const
|
|
737
|
+
const piAsc = url.searchParams.get("asc") === "1";
|
|
738
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
735
739
|
if (piPage) return html(piPage, 200);
|
|
736
740
|
}
|
|
737
741
|
const desc = url.searchParams.get("asc") !== "1";
|
|
@@ -1901,6 +1905,28 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1901
1905
|
}
|
|
1902
1906
|
}
|
|
1903
1907
|
|
|
1908
|
+
const rt = url.pathname.match(REPO_ISSUE_RUNTIME_RE);
|
|
1909
|
+
if (rt) {
|
|
1910
|
+
const [, owner, repo, numStr] = rt;
|
|
1911
|
+
if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
|
|
1912
|
+
const number = Number(numStr);
|
|
1913
|
+
try {
|
|
1914
|
+
const project = await getProject(owner, repo);
|
|
1915
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
1916
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
1917
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1918
|
+
if (!(await canWriteProject(project.id, ctx.user))) {
|
|
1919
|
+
return json({ error: "writer role required" }, 403);
|
|
1920
|
+
}
|
|
1921
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1922
|
+
const runtime = String(form.get("runtime") ?? "").trim().slice(0, 32);
|
|
1923
|
+
await updateIssueRuntime(issue.id, runtime);
|
|
1924
|
+
return json({ ok: true, runtime: runtime === "pi" || runtime === "opencode" ? runtime : "" });
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1904
1930
|
const ci = url.pathname.match(REPO_LIST_RE);
|
|
1905
1931
|
if (ci) {
|
|
1906
1932
|
const [, owner, repo] = ci;
|
|
@@ -1924,7 +1950,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1924
1950
|
await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
1925
1951
|
await autoWireDaemon(project.id, url.origin);
|
|
1926
1952
|
}
|
|
1927
|
-
const
|
|
1953
|
+
const formRuntime = String(form.get("runtime") ?? "").trim();
|
|
1954
|
+
const issueOpts: { model?: string; runtime?: string } = {};
|
|
1955
|
+
if (model) issueOpts.model = model;
|
|
1956
|
+
if (formRuntime === "pi" || formRuntime === "opencode") issueOpts.runtime = formRuntime;
|
|
1957
|
+
const issue = await createIssue(project.id, title, body, ctx.user!.login, issueOpts);
|
|
1928
1958
|
void emitIssueEvent(project.id, issue.id, "opened", url.origin);
|
|
1929
1959
|
return Response.redirect(
|
|
1930
1960
|
`${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
|
package/src/pi-sessions.ts
CHANGED
|
@@ -1,85 +1,247 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "path";
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
import { existsSync } from "fs";
|
|
3
4
|
import { homedir } from "os";
|
|
4
|
-
import { THEME_CSS, escapeHtml } from "./render/layout";
|
|
5
|
+
import { THEME_CSS, escapeHtml, escapeAttr } from "./render/layout";
|
|
6
|
+
import { renderMarkdown } from "./render/markdown";
|
|
5
7
|
|
|
6
8
|
interface PiEvent {
|
|
7
9
|
type?: string;
|
|
8
10
|
timestamp?: string;
|
|
9
11
|
message?: {
|
|
10
12
|
role?: string;
|
|
11
|
-
content?: Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
|
13
|
+
content?: Array<{ type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown; toolCallId?: string; output?: unknown }>;
|
|
14
|
+
usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
|
|
15
|
+
model?: string;
|
|
12
16
|
};
|
|
17
|
+
cwd?: string;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
interface PiToolCall {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
args: string;
|
|
24
|
+
result?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PiCard {
|
|
28
|
+
ts: string;
|
|
29
|
+
role: "user" | "assistant";
|
|
30
|
+
model?: string;
|
|
31
|
+
textParts: string[];
|
|
32
|
+
reasoning: string[];
|
|
33
|
+
tools: PiToolCall[];
|
|
34
|
+
tok?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sessionsRoot(): string {
|
|
38
|
+
return process.env.PI_CODING_AGENT_SESSION_DIR
|
|
39
|
+
?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sessions");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findPiSessionFile(sessionId: string): string | null {
|
|
43
|
+
if (!/^[0-9a-f-]{16,64}$/i.test(sessionId)) return null;
|
|
44
|
+
const root = sessionsRoot();
|
|
18
45
|
if (!existsSync(root)) return null;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
46
|
+
const { readdirSync, statSync } = require("fs") as typeof import("fs");
|
|
47
|
+
const out: string[] = [];
|
|
48
|
+
const walk = (dir: string, depth: number) => {
|
|
49
|
+
let entries: string[] = [];
|
|
50
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
const full = join(dir, e);
|
|
53
|
+
let st;
|
|
54
|
+
try { st = statSync(full); } catch { continue; }
|
|
55
|
+
if (st.isDirectory() && depth < 3) walk(full, depth + 1);
|
|
56
|
+
else if (st.isFile() && e.includes(sessionId) && e.endsWith(".jsonl")) out.push(full);
|
|
27
57
|
}
|
|
58
|
+
};
|
|
59
|
+
walk(root, 0);
|
|
60
|
+
out.sort();
|
|
61
|
+
return out[out.length - 1] ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fmtTs(ts: string): string {
|
|
65
|
+
return ts ? ts.replace("T", " ").replace(/\.\d+Z$/, "Z").slice(5, 19) : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fmtNum(n: number): string {
|
|
69
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
70
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
|
71
|
+
return String(n);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resultText(output: unknown): string {
|
|
75
|
+
if (output == null) return "";
|
|
76
|
+
if (typeof output === "string") return output.slice(0, 4000);
|
|
77
|
+
try { return JSON.stringify(output).slice(0, 4000); } catch { return String(output); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toolCard(t: PiToolCall): string {
|
|
81
|
+
const argStr = t.args.length > 600 ? t.args.slice(0, 600) + "…" : t.args;
|
|
82
|
+
return `<details class="pi-tool"><summary>🔧 ${escapeHtml(t.name)}<span class="pi-tool-args">${escapeHtml(argStr)}</span></summary><div class="tool-io"><pre>${escapeHtml(t.result ? resultText(t.result).slice(0, 4000) : "(无返回)")}</pre></div></details>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderCard(c: PiCard): string {
|
|
86
|
+
const isUser = c.role === "user";
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
for (const r of c.reasoning) {
|
|
89
|
+
if (!r.trim()) continue;
|
|
90
|
+
parts.push(`<details class="reasoning"><summary>💭 Reasoning</summary><div class="tool-io" data-md="${escapeAttr(r.slice(0, 8000))}">${renderMarkdown(r.slice(0, 8000))}</div></details>`);
|
|
91
|
+
}
|
|
92
|
+
for (const t of c.tools) parts.push(toolCard(t));
|
|
93
|
+
for (const tx of c.textParts) {
|
|
94
|
+
if (!tx.trim()) continue;
|
|
95
|
+
parts.push(`<div data-md="${escapeAttr(tx.slice(0, 20000))}">${renderMarkdown(tx.slice(0, 20000))}</div>`);
|
|
28
96
|
}
|
|
29
|
-
return
|
|
97
|
+
return `<div class="msg ${isUser ? "msg-u" : "msg-a"}">
|
|
98
|
+
<div class="mh">
|
|
99
|
+
<span class="role ${isUser ? "role-u" : "role-a"}">${isUser ? "👤 User" : "🤖 Assistant"}</span>
|
|
100
|
+
${c.model ? `<span class="model">${escapeHtml(c.model)}</span>` : ""}
|
|
101
|
+
${c.tok ? `<span class="tok">${c.tok}</span>` : ""}
|
|
102
|
+
<span class="when">${escapeHtml(fmtTs(c.ts))}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="mb">${parts.join("") || "<p><em>(无文本输出)</em></p>"}</div>
|
|
105
|
+
</div>`;
|
|
30
106
|
}
|
|
31
107
|
|
|
32
|
-
export function buildPiSessionPage(sessionId: string, limit =
|
|
108
|
+
export function buildPiSessionPage(sessionId: string, limit: number, asc = false): string | null {
|
|
33
109
|
const file = findPiSessionFile(sessionId);
|
|
34
110
|
if (!file) return null;
|
|
35
|
-
|
|
36
|
-
const entries: { ts: string; role: string; text: string }[] = [];
|
|
37
111
|
const raw = readFileSync(file, "utf8");
|
|
112
|
+
const events: PiEvent[] = [];
|
|
38
113
|
for (const line of raw.split("\n")) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
114
|
+
const l = line.trim();
|
|
115
|
+
if (!l) continue;
|
|
116
|
+
try { events.push(JSON.parse(l) as PiEvent); } catch { /* skip torn tail line */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cards: PiCard[] = [];
|
|
120
|
+
const openTools = new Map<string, PiToolCall>();
|
|
121
|
+
let inputTok = 0, outputTok = 0, cacheTok = 0, peakTok = 0;
|
|
122
|
+
let cwd = "";
|
|
123
|
+
let first = "", last = "";
|
|
124
|
+
for (const e of events) {
|
|
125
|
+
if (e.type === "session" && e.cwd) cwd = e.cwd;
|
|
126
|
+
if (e.timestamp) {
|
|
127
|
+
if (!first) first = e.timestamp;
|
|
128
|
+
last = e.timestamp;
|
|
45
129
|
}
|
|
46
|
-
if (
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
130
|
+
if (e.type !== "message" || !e.message) continue;
|
|
131
|
+
const m = e.message;
|
|
132
|
+
const u = m.usage;
|
|
133
|
+
if (u) {
|
|
134
|
+
inputTok += u.input ?? 0;
|
|
135
|
+
outputTok += u.output ?? 0;
|
|
136
|
+
cacheTok += u.cacheRead ?? 0;
|
|
137
|
+
peakTok = Math.max(peakTok, u.totalTokens ?? 0);
|
|
138
|
+
}
|
|
139
|
+
if (m.role === "user") {
|
|
140
|
+
const text = (m.content ?? []).filter(p => p.type === "text").map(p => p.text ?? "").join("\n");
|
|
141
|
+
cards.push({ ts: e.timestamp ?? "", role: "user", textParts: text ? [text] : [], reasoning: [], tools: [] });
|
|
142
|
+
} else if (m.role === "assistant") {
|
|
143
|
+
const card: PiCard = { ts: e.timestamp ?? "", role: "assistant", model: m.model, textParts: [], reasoning: [], tools: [], tok: u?.output ? `↗ ${fmtNum(u.output)}` : undefined };
|
|
144
|
+
for (const p of m.content ?? []) {
|
|
145
|
+
if (p.type === "thinking" && p.thinking) card.reasoning.push(p.thinking);
|
|
146
|
+
else if (p.type === "text" && p.text) card.textParts.push(p.text);
|
|
147
|
+
else if (p.type === "toolCall") {
|
|
148
|
+
const tc: PiToolCall = {
|
|
149
|
+
id: p.toolCallId ?? `t${openTools.size}`,
|
|
150
|
+
name: p.name ?? "?",
|
|
151
|
+
args: (() => { try { return typeof p.arguments === "string" ? p.arguments : JSON.stringify(p.arguments ?? {}); } catch { return "{}"; } })(),
|
|
152
|
+
};
|
|
153
|
+
card.tools.push(tc);
|
|
154
|
+
openTools.set(tc.id, tc);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
cards.push(card);
|
|
158
|
+
} else {
|
|
159
|
+
for (const p of m.content ?? []) {
|
|
160
|
+
if (p.type === "toolResult") {
|
|
161
|
+
const key = p.toolCallId ?? [...openTools.keys()].pop();
|
|
162
|
+
const tc = key ? openTools.get(key) : undefined;
|
|
163
|
+
if (tc) tc.result = resultText(p.output);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
53
166
|
}
|
|
54
|
-
if (role === "toolResult") text = text || "[tool output]\n";
|
|
55
|
-
if (!text.trim()) continue;
|
|
56
|
-
entries.push({ ts: (ev.timestamp ?? "").slice(11, 19), role, text: text.trim() });
|
|
57
167
|
}
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
168
|
+
|
|
169
|
+
const total = cards.length;
|
|
170
|
+
const shown = asc ? cards.slice(Math.max(0, total - limit)) : cards.slice(Math.max(0, total - limit)).reverse();
|
|
171
|
+
const truncated = total > limit;
|
|
172
|
+
const title = `pi · ${sessionId.slice(0, 13)}…`;
|
|
173
|
+
|
|
174
|
+
const qs = (over: Record<string, string>) => {
|
|
175
|
+
const params = new URLSearchParams(over);
|
|
176
|
+
return `?${params.toString()}`;
|
|
177
|
+
};
|
|
178
|
+
const moreHref = qs({ limit: String(Math.min(5000, limit * 3)) });
|
|
179
|
+
const allHref = qs({ limit: "5000" });
|
|
180
|
+
const ordBtn = (mode: "asc" | "desc") =>
|
|
181
|
+
`<a class="ord-btn ${asc === (mode === "asc") ? "active" : ""}" href="${qs({ limit: String(limit), asc: mode === "asc" ? "1" : "0" })}">${mode === "asc" ? "正序" : "倒序"}</a>`;
|
|
182
|
+
|
|
183
|
+
const body = shown.map(renderCard).join("");
|
|
184
|
+
const moreBar = truncated
|
|
185
|
+
? `<div class="more-bar">共 ${total} 条,当前显示最新 ${limit} 条 · <a href="${moreHref}">加载更多</a> · <a href="${allHref}">查看全部</a></div>`
|
|
186
|
+
: "";
|
|
187
|
+
|
|
188
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
189
|
+
<title>${escapeHtml(title)}</title>
|
|
69
190
|
<style>${THEME_CSS}
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
-
.
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
.
|
|
77
|
-
.
|
|
191
|
+
.meta-bar{max-width:900px;margin:0 auto;padding:.8rem 1rem .2rem}
|
|
192
|
+
.meta-bar h1{font-size:17px;margin:0 0 .35rem;overflow-wrap:anywhere}
|
|
193
|
+
.meta-status{display:flex;gap:.6rem;flex-wrap:wrap;font-size:12px;color:var(--text-muted)}
|
|
194
|
+
.meta-status .sid{font-family:ui-monospace,monospace}
|
|
195
|
+
.mbar{max-width:900px;margin:.6rem auto;padding:0 1rem;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
|
|
196
|
+
.mbar .note{font-size:12px;color:var(--text-muted)}
|
|
197
|
+
.ord{margin-left:auto;display:flex;gap:.3rem}
|
|
198
|
+
.ord-btn{padding:.25rem .7rem;border-radius:6px;border:1px solid var(--border);font-size:12px;color:var(--text-muted);text-decoration:none}
|
|
199
|
+
.ord-btn.active{background:var(--bg-muted);color:var(--text);font-weight:600}
|
|
200
|
+
#mlist{max-width:900px;margin:0 auto;padding:.5rem 1rem 3rem}
|
|
201
|
+
.msg{position:relative;background:var(--bg-elev);border:1px solid var(--border);border-left:3px solid var(--border);border-radius:8px;padding:.6rem .9rem;margin-bottom:1rem;overflow:hidden}
|
|
202
|
+
.msg.msg-u{border-left-color:var(--human);background:color-mix(in srgb,var(--human) 4%,var(--bg-elev))}
|
|
203
|
+
.msg.msg-a{border-left-color:var(--bot);background:color-mix(in srgb,var(--bot) 4%,var(--bg-elev))}
|
|
204
|
+
.mh{display:flex;align-items:center;gap:.5rem;font-size:13px;flex-wrap:wrap;margin-bottom:.35rem}
|
|
205
|
+
.mh .role{font-weight:600;font-size:11px;padding:.05rem .45rem;border-radius:4px;line-height:1.7}
|
|
206
|
+
.mh .role-u{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
|
|
207
|
+
.mh .role-a{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
|
|
208
|
+
.mh .model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted)}
|
|
209
|
+
.mh .when{color:var(--text-muted);font-size:12px;margin-left:auto}
|
|
210
|
+
.mh .tok{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text-muted)}
|
|
211
|
+
.mb{overflow-wrap:anywhere;word-break:break-word;line-height:1.55}
|
|
212
|
+
.mb p{margin:.4rem 0}
|
|
213
|
+
.mb pre{background:var(--code-bg);padding:.6rem;border-radius:6px;overflow:auto;font-size:12.5px}
|
|
214
|
+
.mb code{background:var(--code-bg);padding:.1em .35em;border-radius:4px;font-size:12.5px}
|
|
215
|
+
.mb details{margin:.3rem 0;border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
|
216
|
+
.mb summary{cursor:pointer;padding:.4rem .6rem;background:var(--bg-muted);font-size:13px;display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
|
|
217
|
+
.mb details.reasoning{border-left:3px solid color-mix(in srgb,var(--system) 45%,transparent)}
|
|
218
|
+
.mb details.reasoning>.tool-io{background:color-mix(in srgb,var(--system) 7%,var(--bg-elev))}
|
|
219
|
+
.mb .tool-io{position:relative;padding:.5rem .6rem}
|
|
220
|
+
.mb .tool-io pre{margin:.2rem 0}
|
|
221
|
+
.pi-tool summary .pi-tool-args{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
|
|
222
|
+
.more-bar{max-width:900px;margin:1rem auto 0;padding:.8rem 1rem;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--text-muted);font-size:13px}
|
|
223
|
+
.more-bar a{font-weight:600}
|
|
78
224
|
</style></head><body>
|
|
79
|
-
<header class="
|
|
80
|
-
<
|
|
81
|
-
<
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
225
|
+
<header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px;flex-wrap:wrap">
|
|
226
|
+
<a href="/" style="color:var(--header-text)">🏠</a><span style="opacity:.5">/</span>
|
|
227
|
+
<a href="/sessions" style="color:var(--header-text)">会话</a><span style="opacity:.5">/</span>
|
|
228
|
+
<span style="opacity:.85">${escapeHtml(title)}</span>
|
|
229
|
+
</header>
|
|
230
|
+
<div class="meta-bar">
|
|
231
|
+
<h1>${escapeHtml(title)}</h1>
|
|
232
|
+
<div class="meta-status">
|
|
233
|
+
<span class="sid">${escapeHtml(sessionId)}</span>
|
|
234
|
+
${cwd ? `<span>📁 ${escapeHtml(dirname(cwd))}</span>` : ""}
|
|
235
|
+
${first ? `<span>创建 ${escapeHtml(fmtTs(first))}</span>` : ""}
|
|
236
|
+
${last ? `<span>更新 ${escapeHtml(fmtTs(last))}</span>` : ""}
|
|
237
|
+
<span>${total} 条消息</span>
|
|
238
|
+
<span>🧮 峰值 ${fmtNum(peakTok)} · 累计 ↗ ${fmtNum(outputTok)} · cache ${fmtNum(cacheTok)}</span>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="mbar">
|
|
242
|
+
<span class="note">📖 只读 · pi 运行时会话</span>
|
|
243
|
+
<span class="ord">${ordBtn("asc")}${ordBtn("desc")}</span>
|
|
244
|
+
</div>
|
|
245
|
+
<main id="mlist">${body || `<div style="padding:2rem;text-align:center;color:var(--text-muted)">此会话没有消息</div>`}${moreBar}</main>
|
|
246
|
+
</body></html>`;
|
|
85
247
|
}
|
package/src/render/layout.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface LayoutProps {
|
|
|
24
24
|
customActions?: IssueAction[];
|
|
25
25
|
extraStatusBadges?: Record<string, { cls: string; label: string }>;
|
|
26
26
|
modelSelect?: { current: string; options: { id: string; label: string }[] } | null;
|
|
27
|
+
runtimeSelect?: { current: string } | null;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export const THEME_CSS = `
|
|
@@ -206,6 +207,13 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
206
207
|
? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 恢复接单</button>`
|
|
207
208
|
: `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="关闭此 issue 的自动接单">🔕 暂停接单</button>`
|
|
208
209
|
: "";
|
|
210
|
+
const runtimeSelectHtml = props.runtimeSelect && showActions
|
|
211
|
+
? `<span class="model-select-wrap"><select class="model-select" id="issueRuntimeSelect" title="此 issue 的运行时(新会话生效)">
|
|
212
|
+
<option value="" ${props.runtimeSelect.current === "" ? "selected" : ""}>默认运行时</option>
|
|
213
|
+
<option value="opencode" ${props.runtimeSelect.current === "opencode" ? "selected" : ""}>opencode</option>
|
|
214
|
+
<option value="pi" ${props.runtimeSelect.current === "pi" ? "selected" : ""}>pi</option>
|
|
215
|
+
</select><button type="button" class="action-btn model-save-btn" id="issueRuntimeSave" title="保存运行时选择">💾</button></span>`
|
|
216
|
+
: "";
|
|
209
217
|
const modelSelectHtml = props.modelSelect && props.modelSelect.options.length > 0 && showActions
|
|
210
218
|
? `<span class="model-select-wrap"><select class="model-select" id="issueModelSelect" title="此 issue 的模型(覆盖项目/全局默认)">
|
|
211
219
|
<option value="">默认模型</option>
|
|
@@ -234,7 +242,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
234
242
|
<div class="meta-status">
|
|
235
243
|
<span class="state-badge ${stateClass}">${stateLabel}</span>
|
|
236
244
|
${aiBadgeHtml}
|
|
237
|
-
${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
|
|
245
|
+
${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${runtimeSelectHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
|
|
238
246
|
const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
|
|
239
247
|
if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
|
|
240
248
|
if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);
|
package/src/schema-mysql.sql
CHANGED
|
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
55
55
|
closed_at VARCHAR(40) DEFAULT NULL,
|
|
56
56
|
ai_status VARCHAR(32) NOT NULL DEFAULT '',
|
|
57
57
|
model VARCHAR(128) NOT NULL DEFAULT '',
|
|
58
|
+
runtime VARCHAR(32) NOT NULL DEFAULT '',
|
|
58
59
|
upstream_issue_number INT DEFAULT NULL,
|
|
59
60
|
UNIQUE (project_id, number),
|
|
60
61
|
UNIQUE uq_issues_project_upstream (project_id, upstream_issue_number),
|
|
@@ -78,7 +79,7 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
|
|
|
78
79
|
UNIQUE uq_comments_upstream (upstream_comment_id),
|
|
79
80
|
CONSTRAINT {{fk_comments_issue}} FOREIGN KEY (issue_id) REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
80
81
|
CONSTRAINT {{fk_comments_author}} FOREIGN KEY (author) REFERENCES {{users}}(login),
|
|
81
|
-
model VARCHAR(128) NOT NULL DEFAULT ''
|
|
82
|
+
model VARCHAR(128) NOT NULL DEFAULT '',
|
|
82
83
|
CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
|
|
83
84
|
CREATE INDEX comments_author ON {{comments}} (author);
|
|
84
85
|
|
package/src/schema.sql
CHANGED
|
@@ -67,6 +67,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
67
67
|
ai_status TEXT NOT NULL DEFAULT '',
|
|
68
68
|
-- Resolved "provider/model" for this issue. Empty = inherit project/global default.
|
|
69
69
|
model TEXT NOT NULL DEFAULT '',
|
|
70
|
+
-- Runtime backend pinned for this issue ('' = daemon default, 'opencode', 'pi').
|
|
71
|
+
runtime TEXT NOT NULL DEFAULT '',
|
|
70
72
|
-- Upstream Gitea issue number this row was imported from (NULL = native).
|
|
71
73
|
upstream_issue_number INTEGER,
|
|
72
74
|
UNIQUE (project_id, number)
|
|
@@ -86,7 +88,8 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
|
|
|
86
88
|
created_at TEXT NOT NULL,
|
|
87
89
|
updated_at TEXT NOT NULL DEFAULT '',
|
|
88
90
|
upstream_comment_id INTEGER,
|
|
89
|
-
model TEXT NOT NULL DEFAULT ''
|
|
91
|
+
model TEXT NOT NULL DEFAULT '',
|
|
92
|
+
runtime TEXT NOT NULL DEFAULT ''
|
|
90
93
|
);
|
|
91
94
|
CREATE INDEX IF NOT EXISTS comments_issue_created
|
|
92
95
|
ON {{comments}} (issue_id, created_at);
|
|
@@ -115,4 +115,37 @@
|
|
|
115
115
|
document.addEventListener("change", function (e) {
|
|
116
116
|
if (e.target && e.target.id === "issueModelSelect") saveModel(document.getElementById("issueModelSave"));
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
function saveRuntime(saveBtn) {
|
|
120
|
+
var sel = document.getElementById("issueRuntimeSelect");
|
|
121
|
+
if (!sel || !saveBtn) return;
|
|
122
|
+
saveBtn.disabled = true;
|
|
123
|
+
var path = location.pathname.split("/").slice(0, 5).join("/");
|
|
124
|
+
fetch(path + "/runtime", {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
127
|
+
body: "runtime=" + encodeURIComponent(sel.value)
|
|
128
|
+
})
|
|
129
|
+
.then(function (r) { return r.json(); })
|
|
130
|
+
.then(function (d) {
|
|
131
|
+
saveBtn.disabled = false;
|
|
132
|
+
if (d.ok) { saveBtn.textContent = "✓"; setTimeout(function () { saveBtn.textContent = "💾"; }, 1200); }
|
|
133
|
+
else alert(d.error || "保存失败");
|
|
134
|
+
})
|
|
135
|
+
.catch(function (err) {
|
|
136
|
+
saveBtn.disabled = false;
|
|
137
|
+
alert("网络错误: " + err);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
document.addEventListener("click", function (e) {
|
|
142
|
+
var saveBtn = e.target.closest("#issueRuntimeSave");
|
|
143
|
+
if (!saveBtn) return;
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
saveRuntime(saveBtn);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
document.addEventListener("change", function (e) {
|
|
149
|
+
if (e.target && e.target.id === "issueRuntimeSelect") saveRuntime(document.getElementById("issueRuntimeSave"));
|
|
150
|
+
});
|
|
118
151
|
})();
|
package/src/store.ts
CHANGED
|
@@ -75,6 +75,7 @@ export interface IssueRow {
|
|
|
75
75
|
closed_at: string | null;
|
|
76
76
|
ai_status: string;
|
|
77
77
|
model: string;
|
|
78
|
+
runtime: string;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export interface IssueWithMeta extends IssueRow {
|
|
@@ -453,6 +454,7 @@ export interface CreateIssueOpts {
|
|
|
453
454
|
state?: "open" | "closed";
|
|
454
455
|
closedAt?: string | null;
|
|
455
456
|
model?: string;
|
|
457
|
+
runtime?: string;
|
|
456
458
|
upstreamIssueNumber?: number;
|
|
457
459
|
}
|
|
458
460
|
|
|
@@ -484,8 +486,8 @@ export async function createIssue(
|
|
|
484
486
|
"SELECT COALESCE(MAX(number), 0) + 1 AS n FROM {{issues}} WHERE project_id = ?", [projectId]
|
|
485
487
|
))!;
|
|
486
488
|
const info = await getDB().run(
|
|
487
|
-
"INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at, model, upstream_issue_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
488
|
-
[projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt, opts.model ?? "", opts.upstreamIssueNumber ?? null]
|
|
489
|
+
"INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at, model, runtime, upstream_issue_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
490
|
+
[projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt, opts.model ?? "", opts.runtime ?? "", opts.upstreamIssueNumber ?? null]
|
|
489
491
|
);
|
|
490
492
|
await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, projectId]);
|
|
491
493
|
return (await getIssueById(info.insertId))!;
|
|
@@ -525,6 +527,11 @@ export async function updateIssueModel(issueId: number, model: string): Promise<
|
|
|
525
527
|
await getDB().run("UPDATE {{issues}} SET model = ?, updated_at = ? WHERE id = ?", [clean, now(), issueId]);
|
|
526
528
|
}
|
|
527
529
|
|
|
530
|
+
export async function updateIssueRuntime(issueId: number, runtime: string): Promise<void> {
|
|
531
|
+
const v = runtime === "pi" || runtime === "opencode" ? runtime : "";
|
|
532
|
+
await getDB().run("UPDATE {{issues}} SET runtime = ? WHERE id = ?", [v, issueId]);
|
|
533
|
+
}
|
|
534
|
+
|
|
528
535
|
export interface UpstreamSyncRow {
|
|
529
536
|
id: number;
|
|
530
537
|
project_id: number;
|
package/src/views/issueNew.ts
CHANGED
|
@@ -15,6 +15,11 @@ export function buildIssueNew(owner: string, repo: string, writesEnabled: boolea
|
|
|
15
15
|
<input type="text" name="title" placeholder="标题(必填)" required maxlength="255" class="new-title">
|
|
16
16
|
<textarea name="body" rows="14" placeholder="正文(支持 Markdown)…"></textarea>
|
|
17
17
|
${modelSelect}
|
|
18
|
+
<select name="runtime" class="new-model">
|
|
19
|
+
<option value="">默认运行时(daemon 设置)</option>
|
|
20
|
+
<option value="opencode">opencode</option>
|
|
21
|
+
<option value="pi">pi</option>
|
|
22
|
+
</select>
|
|
18
23
|
<div class="new-actions"><a class="new-cancel" href="${escapeAttr(listHref)}">取消</a><button type="submit">创建工单</button></div>
|
|
19
24
|
</form>`
|
|
20
25
|
: `<div class="composer-ro">只读模式:创建工单未启用(WORK_WRITES_ENABLED=false)</div>`;
|
package/src/views/issueThread.ts
CHANGED
|
@@ -161,6 +161,7 @@ export async function buildIssueThread(
|
|
|
161
161
|
modelSelect: cfg.writesEnabled !== false
|
|
162
162
|
? { current: issue.model ?? "", options: (await listCachedModels()).map((m) => ({ id: m.id, label: m.label })) }
|
|
163
163
|
: null,
|
|
164
|
+
runtimeSelect: cfg.writesEnabled !== false ? { current: issue.runtime ?? "" } : null,
|
|
164
165
|
},
|
|
165
166
|
safeJsonEmbed(payload),
|
|
166
167
|
displayViews.map((v) => renderCommentCard(v, cfg)).join("")
|
package/src/webhooks.ts
CHANGED
|
@@ -269,6 +269,7 @@ interface PayloadRepository {
|
|
|
269
269
|
// `--model <X>` on opencode spawn. Empty string = no override (let opencode
|
|
270
270
|
// pick). Gitea-strict consumers ignore unknown fields per JSON POST rules.
|
|
271
271
|
ework_model?: string;
|
|
272
|
+
ework_runtime?: string;
|
|
272
273
|
}
|
|
273
274
|
|
|
274
275
|
interface PayloadIssue {
|
|
@@ -364,7 +365,12 @@ function buildUserFromRow(user: UserRow, origin: string): PayloadUser {
|
|
|
364
365
|
};
|
|
365
366
|
}
|
|
366
367
|
|
|
367
|
-
function buildRepository(
|
|
368
|
+
function buildRepository(
|
|
369
|
+
project: ProjectRow,
|
|
370
|
+
origin: string,
|
|
371
|
+
model?: string,
|
|
372
|
+
runtime?: string,
|
|
373
|
+
): PayloadRepository {
|
|
368
374
|
const fullName = `${project.owner}/${project.name}`;
|
|
369
375
|
const htmlUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
|
|
370
376
|
// clone_url must be a real Git remote (ework-web is NOT a Git server). Use the
|
|
@@ -399,6 +405,7 @@ function buildRepository(project: ProjectRow, origin: string, model?: string): P
|
|
|
399
405
|
// Only attach ework_model when non-empty (keeps payload compact + lets
|
|
400
406
|
// Gitea-strict consumers ignore the field entirely on no-op cases).
|
|
401
407
|
if (model) repo.ework_model = model;
|
|
408
|
+
if (runtime) repo.ework_runtime = runtime;
|
|
402
409
|
return repo;
|
|
403
410
|
}
|
|
404
411
|
|
|
@@ -436,7 +443,7 @@ function buildIssue(
|
|
|
436
443
|
closed_at: issue.closed_at,
|
|
437
444
|
due_date: null,
|
|
438
445
|
pull_request: null,
|
|
439
|
-
repository: buildRepository(project, origin, model),
|
|
446
|
+
repository: buildRepository(project, origin, model, issue.runtime || undefined),
|
|
440
447
|
user: buildUser(issue.author, origin),
|
|
441
448
|
ai_status: issue.ai_status ?? "",
|
|
442
449
|
};
|
|
@@ -475,7 +482,7 @@ function buildCommentPayload(
|
|
|
475
482
|
action: "created",
|
|
476
483
|
issue: buildIssue(issue, project, commentCount, origin, model, labels),
|
|
477
484
|
comment: buildComment(issue, comment, project, origin),
|
|
478
|
-
repository: buildRepository(project, origin, model),
|
|
485
|
+
repository: buildRepository(project, origin, model, issue.runtime || undefined),
|
|
479
486
|
sender: buildUser(comment.author, origin),
|
|
480
487
|
};
|
|
481
488
|
}
|
|
@@ -492,7 +499,7 @@ function buildIssuePayload(
|
|
|
492
499
|
return {
|
|
493
500
|
action,
|
|
494
501
|
issue: buildIssue(issue, project, commentCount, origin, model, labels),
|
|
495
|
-
repository: buildRepository(project, origin, model),
|
|
502
|
+
repository: buildRepository(project, origin, model, issue.runtime || undefined),
|
|
496
503
|
sender: buildUser(issue.author, origin),
|
|
497
504
|
};
|
|
498
505
|
}
|