@hienlh/ppm 0.19.3 → 0.19.4
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/CHANGELOG.md +5 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/accounts-settings-section-CCFQV-RB.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BqFT1EUB.js → ai-resource-editor-BTWds-aC.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CY6spbEx.js → ai-settings-section-CmoBsSpY.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Po3K8j-g.js → ai-settings-section-DjbYj4wK.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-L9Z-nej_.js → appearance-settings-section-vLmakLtA.js} +1 -1
- package/dist/web/assets/chat-tab-CLRlS8Jc.js +15 -0
- package/dist/web/assets/code-editor-CCwVu5F5.js +7 -0
- package/dist/web/assets/{conflict-editor-BoRPwL3L.js → conflict-editor-CvWYcbwD.js} +1 -1
- package/dist/web/assets/{csv-preview-BpoJqZQi.js → csv-preview-DzuDuyE_.js} +1 -1
- package/dist/web/assets/{database-viewer-I-9cER1L.js → database-viewer-D4XGEDHm.js} +1 -1
- package/dist/web/assets/{diff-viewer-_xHZsxvr.js → diff-viewer-B5N1X61R.js} +1 -1
- package/dist/web/assets/explorer-body-BxLLjthF.js +2 -0
- package/dist/web/assets/{explorer-window-content-DD6Z15pm.js → explorer-window-content-CL1Cipj3.js} +1 -1
- package/dist/web/assets/{extension-manager-section-Cu9D9Udt.js → extension-manager-section-BpyGq15A.js} +1 -1
- package/dist/web/assets/{extension-webview-Diz2OzVq.js → extension-webview-BM8FXhuN.js} +1 -1
- package/dist/web/assets/{files-settings-section-B1e7BgKQ.js → files-settings-section-B0_aR1nL.js} +1 -1
- package/dist/web/assets/{general-settings-section-DWdHHRDy.js → general-settings-section-C1EJzjhU.js} +1 -1
- package/dist/web/assets/{git-log-panel-CMMsvU64.js → git-log-panel-D1yGTUE_.js} +1 -1
- package/dist/web/assets/{glide-data-grid-pKQuyYFj.js → glide-data-grid-Cu0STKML.js} +11 -11
- package/dist/web/assets/{group-chat-tab-U5u0Loii.js → group-chat-tab-BfcvkRX5.js} +1 -1
- package/dist/web/assets/{index--KWVW-Ga.js → index-B1bzKRjJ.js} +4 -4
- package/dist/web/assets/index-nP3uxXvR.css +2 -0
- package/dist/web/assets/{jira-watcher-section-uA5oyB8s.js → jira-watcher-section-DZh-Z2MK.js} +1 -1
- package/dist/web/assets/{keybindings-store-Ccf0etMJ.js → keybindings-store-CFwgcnpZ.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-B7IxJHiI.js → keyboard-shortcuts-section-JBtcJGM3.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DDcIGQEd.js → markdown-renderer-Bo8GgGXl.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CY1epGRc.js → markdown-renderer-DY60UZNW.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CqkwbCRl.js → mobile-explorer-sheet-C59rPw7Z.js} +1 -1
- package/dist/web/assets/{notification-store-BNSYk3E7.js → notification-store-Bd6L0K_Q.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-v_628h4B.js → notifications-settings-section-ARQlF-6E.js} +1 -1
- package/dist/web/assets/{pdf-preview-CI3q1MnH.js → pdf-preview-CRShdbBS.js} +1 -1
- package/dist/web/assets/{postgres-viewer-jaR0FYJ_.js → postgres-viewer-BNT-4yYl.js} +2 -2
- package/dist/web/assets/{ppmbot-settings-section-BET4VmKb.js → ppmbot-settings-section-Bq4YAcFW.js} +1 -1
- package/dist/web/assets/{process-table-D1r-Gupi.js → process-table-CjiXW0rg.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-0DvNh1B-.js → proxy-settings-section-wRbiyQiA.js} +1 -1
- package/dist/web/assets/{query-audit-section-BA5rT6Sh.js → query-audit-section-C5RmhRSu.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-CPa3XElX.js → remote-desktop-mobile-sheet-BF-75_03.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-De_a4skd.js → remote-desktop-mobile-view-C9KR9I9G.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-CPrDIv2d.js → remote-desktop-readiness-gate-BnKJicB2.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CG1StfCE.js → remote-desktop-window-content-awXsNw_R.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-B2f7V7Xr.js → schedules-settings-section-BOecKJzl.js} +1 -1
- package/dist/web/assets/{settings-body-omv04jbb.js → settings-body-DGi5DXY6.js} +2 -2
- package/dist/web/assets/{settings-tab-NdnBTqyj.js → settings-tab-GM9zTRul.js} +1 -1
- package/dist/web/assets/{settings-window-content-Chou1JGa.js → settings-window-content-Dvhxo4_H.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Dk9y3jqy.js → sql-query-editor-BmkGFzcL.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-4xFhkLYy.js → sqlite-viewer-DbziVQXQ.js} +1 -1
- package/dist/web/assets/{system-monitor-body-B_1iMVOI.js → system-monitor-body-CbHW5aYm.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-B2j9mWBW.js → system-monitor-tab-C7EZLMIo.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-BOjq53uU.js → system-monitor-window-content-C_w4w81e.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-CW_dlYlW.js → tab-host-window-content-BSP1SEXS.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CkR4qO7V.js → team-member-sheet-C9N0iA1-.js} +2 -2
- package/dist/web/assets/{team-member-window-content-CmF5ia3n.js → team-member-window-content-CM2QFv-s.js} +1 -1
- package/dist/web/assets/{terminal-tab-HXZkicIc.js → terminal-tab-jWYELm8y.js} +2 -2
- package/dist/web/assets/{tool-cards-jA1BTLZj.js → tool-cards-jefdOWVJ.js} +4 -4
- package/dist/web/assets/use-accounts-data-MhMl0Y4Z.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BcQqKVbE.js → use-monaco-theme-BbAd7DXr.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-DY5BWRTo.js → use-remote-desktop-display-choice-DlfMIWq3.js} +1 -1
- package/dist/web/assets/{use-websocket-DetaY8OI.js → use-websocket-Bq8X_SUI.js} +1 -1
- package/dist/web/assets/{video-preview-C0uH2228.js → video-preview-BGh3B0Lu.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/services/proxy-agent-anthropic-bridge.ts +93 -0
- package/src/services/proxy-agent-bridge.ts +100 -0
- package/src/services/proxy-agent-turn.ts +161 -0
- package/src/services/proxy-anthropic-format.ts +157 -0
- package/src/services/proxy-openai-format.ts +148 -0
- package/src/web/components/chat/usage-badge.tsx +41 -4
- package/src/web/components/settings/accounts/account-card.tsx +27 -3
- package/src/web/components/settings/accounts/accounts-pane-header.tsx +22 -2
- package/src/web/components/settings/accounts/claude-accounts-section.tsx +4 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +11 -4
- package/dist/web/assets/accounts-settings-section-LtSVxNNp.js +0 -1
- package/dist/web/assets/chat-tab-BIO-VhWT.js +0 -15
- package/dist/web/assets/code-editor-B9lVjElM.js +0 -7
- package/dist/web/assets/explorer-body-DapwsOnp.js +0 -2
- package/dist/web/assets/index-CvGx7wBQ.css +0 -2
- package/dist/web/assets/use-accounts-data-BI_5qNvr.js +0 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API wire format for the provider-scoped proxy.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `proxy-openai-format.ts`: same job, the other dialect, so
|
|
5
|
+
* `/proxy/<provider>/v1/messages` and `/proxy/<provider>/v1/chat/completions`
|
|
6
|
+
* expose the same agent through whichever SDK the caller already uses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One entry of the Anthropic `messages` array. */
|
|
10
|
+
export interface AnthropicMessage {
|
|
11
|
+
role?: string;
|
|
12
|
+
content?: string | Array<{ type?: string; text?: string }> | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AnthropicMessagesBody {
|
|
16
|
+
model?: string;
|
|
17
|
+
messages?: AnthropicMessage[];
|
|
18
|
+
/** Anthropic carries instructions top-level, not as a message role. */
|
|
19
|
+
system?: string | Array<{ type?: string; text?: string }>;
|
|
20
|
+
stream?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Concatenate the text blocks of a content field, ignoring everything else. */
|
|
24
|
+
function textOf(content: AnthropicMessage["content"] | AnthropicMessagesBody["system"]): string {
|
|
25
|
+
if (typeof content === "string") return content;
|
|
26
|
+
if (Array.isArray(content)) {
|
|
27
|
+
return content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Flatten an Anthropic request into a single prompt plus its system text.
|
|
34
|
+
*
|
|
35
|
+
* Non-text blocks (notably `image`) are dropped: the agent turn has no way to
|
|
36
|
+
* carry an inline image today.
|
|
37
|
+
*/
|
|
38
|
+
export function buildPromptFromAnthropicMessages(
|
|
39
|
+
body: AnthropicMessagesBody,
|
|
40
|
+
): { prompt: string; systemPrompt?: string } {
|
|
41
|
+
const parts = (body.messages ?? []).map((m) => {
|
|
42
|
+
const role = m.role === "assistant" ? "Assistant" : "Human";
|
|
43
|
+
return `${role}: ${textOf(m.content)}`;
|
|
44
|
+
});
|
|
45
|
+
const systemPrompt = textOf(body.system) || undefined;
|
|
46
|
+
return { prompt: parts.join("\n\n"), systemPrompt };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when any message carries a content block this format cannot forward. */
|
|
50
|
+
export function hasUnsupportedAnthropicBlocks(body: AnthropicMessagesBody): boolean {
|
|
51
|
+
return (body.messages ?? []).some((m) =>
|
|
52
|
+
Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text"),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const JSON_HEADERS = {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"Access-Control-Allow-Origin": "*",
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
export const ANTHROPIC_SSE_HEADERS = {
|
|
62
|
+
"Content-Type": "text/event-stream",
|
|
63
|
+
"Cache-Control": "no-cache",
|
|
64
|
+
"Connection": "keep-alive",
|
|
65
|
+
"Access-Control-Allow-Origin": "*",
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
/** Anthropic errors are `{type:"error", error:{type, message}}`, not OpenAI's shape. */
|
|
69
|
+
export function anthropicError(status: number, message: string): Response {
|
|
70
|
+
const type = status === 404 ? "not_found_error" : status === 400 ? "invalid_request_error" : "api_error";
|
|
71
|
+
return new Response(
|
|
72
|
+
JSON.stringify({ type: "error", error: { type, message } }),
|
|
73
|
+
{ status, headers: JSON_HEADERS },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AnthropicUsage {
|
|
78
|
+
inputTokens: number;
|
|
79
|
+
outputTokens: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function messageId(): string {
|
|
83
|
+
return `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build a non-streaming Messages response. */
|
|
87
|
+
export function messageResponse(text: string, model: string, usage?: AnthropicUsage): Response {
|
|
88
|
+
return new Response(JSON.stringify({
|
|
89
|
+
id: messageId(),
|
|
90
|
+
type: "message",
|
|
91
|
+
role: "assistant",
|
|
92
|
+
model,
|
|
93
|
+
content: [{ type: "text", text }],
|
|
94
|
+
stop_reason: "end_turn",
|
|
95
|
+
stop_sequence: null,
|
|
96
|
+
usage: {
|
|
97
|
+
input_tokens: usage?.inputTokens ?? 0,
|
|
98
|
+
output_tokens: usage?.outputTokens ?? 0,
|
|
99
|
+
},
|
|
100
|
+
}), { status: 200, headers: JSON_HEADERS });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Emits the Anthropic streaming event sequence for one response.
|
|
105
|
+
*
|
|
106
|
+
* Anthropic SSE is named-event based (`event:` line plus `data:`), and clients
|
|
107
|
+
* reject a stream that skips the message_start / content_block_* / message_stop
|
|
108
|
+
* envelope — so the envelope is written even when no text arrives.
|
|
109
|
+
*/
|
|
110
|
+
export class MessageStreamWriter {
|
|
111
|
+
private readonly encoder = new TextEncoder();
|
|
112
|
+
private readonly id = messageId();
|
|
113
|
+
private outputTokens = 0;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly controller: ReadableStreamDefaultController<Uint8Array>,
|
|
117
|
+
private readonly model: string,
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
private emit(event: string, data: Record<string, unknown>): void {
|
|
121
|
+
this.controller.enqueue(this.encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** message_start + the single text block every response here uses. */
|
|
125
|
+
open(inputTokens = 0): void {
|
|
126
|
+
this.emit("message_start", {
|
|
127
|
+
type: "message_start",
|
|
128
|
+
message: {
|
|
129
|
+
id: this.id, type: "message", role: "assistant", model: this.model,
|
|
130
|
+
content: [], stop_reason: null, stop_sequence: null,
|
|
131
|
+
usage: { input_tokens: inputTokens, output_tokens: 0 },
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
this.emit("content_block_start", {
|
|
135
|
+
type: "content_block_start", index: 0, content_block: { type: "text", text: "" },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
text(content: string): void {
|
|
140
|
+
if (!content) return;
|
|
141
|
+
this.emit("content_block_delta", {
|
|
142
|
+
type: "content_block_delta", index: 0, delta: { type: "text_delta", text: content },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Close the block and the message. `usage` reports what the turn actually cost. */
|
|
147
|
+
close(usage?: AnthropicUsage, stopReason = "end_turn"): void {
|
|
148
|
+
this.emit("content_block_stop", { type: "content_block_stop", index: 0 });
|
|
149
|
+
this.emit("message_delta", {
|
|
150
|
+
type: "message_delta",
|
|
151
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
152
|
+
usage: { output_tokens: usage?.outputTokens ?? this.outputTokens },
|
|
153
|
+
});
|
|
154
|
+
this.emit("message_stop", { type: "message_stop" });
|
|
155
|
+
this.controller.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Chat Completions wire format — the shared translation layer between
|
|
3
|
+
* PPM's proxy bridges and the shape an OpenAI client expects.
|
|
4
|
+
*
|
|
5
|
+
* Both proxy bridges speak this: the Claude SDK bridge (`proxy-openai-bridge.ts`)
|
|
6
|
+
* and the provider-agnostic agent bridge (`proxy-agent-bridge.ts`). Keeping the
|
|
7
|
+
* wire format in one place is what stops the two from drifting into subtly
|
|
8
|
+
* different `chat.completion` payloads.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One entry of the OpenAI `messages` array (content is string or block array). */
|
|
12
|
+
export interface OpenAiMessage {
|
|
13
|
+
role?: string;
|
|
14
|
+
content?: string | Array<{ type?: string; text?: string }> | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OpenAiChatBody {
|
|
18
|
+
model?: string;
|
|
19
|
+
messages?: OpenAiMessage[];
|
|
20
|
+
stream?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Flatten OpenAI `messages` into a single prompt plus a system prompt.
|
|
25
|
+
*
|
|
26
|
+
* Non-text blocks (notably `image_url`) are dropped: neither bridge can carry
|
|
27
|
+
* an inline image today. Callers that must not silently lose an attachment
|
|
28
|
+
* should check `hasUnsupportedBlocks` first.
|
|
29
|
+
*/
|
|
30
|
+
export function buildPromptFromOpenAiMessages(
|
|
31
|
+
body: OpenAiChatBody,
|
|
32
|
+
): { prompt: string; systemPrompt?: string } {
|
|
33
|
+
const messages = body.messages ?? [];
|
|
34
|
+
let systemPrompt: string | undefined;
|
|
35
|
+
const conversationParts: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (const m of messages) {
|
|
38
|
+
const text = typeof m.content === "string"
|
|
39
|
+
? m.content
|
|
40
|
+
: Array.isArray(m.content)
|
|
41
|
+
? m.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n")
|
|
42
|
+
: String(m.content ?? "");
|
|
43
|
+
|
|
44
|
+
if (m.role === "system") {
|
|
45
|
+
systemPrompt = systemPrompt ? `${systemPrompt}\n${text}` : text;
|
|
46
|
+
} else {
|
|
47
|
+
const role = m.role === "assistant" ? "Assistant" : "Human";
|
|
48
|
+
conversationParts.push(`${role}: ${text}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { prompt: conversationParts.join("\n\n"), systemPrompt };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when any message carries a content block this format cannot forward. */
|
|
56
|
+
export function hasUnsupportedBlocks(body: OpenAiChatBody): boolean {
|
|
57
|
+
return (body.messages ?? []).some((m) =>
|
|
58
|
+
Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text"),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const JSON_HEADERS = {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
"Access-Control-Allow-Origin": "*",
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
export const SSE_HEADERS = {
|
|
68
|
+
"Content-Type": "text/event-stream",
|
|
69
|
+
"Cache-Control": "no-cache",
|
|
70
|
+
"Connection": "keep-alive",
|
|
71
|
+
"Access-Control-Allow-Origin": "*",
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
export function openAiError(status: number, message: string): Response {
|
|
75
|
+
return new Response(
|
|
76
|
+
JSON.stringify({ error: { message, type: "server_error", code: String(status) } }),
|
|
77
|
+
{ status, headers: JSON_HEADERS },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Token counts for the `usage` object; omitted fields report as 0. */
|
|
82
|
+
export interface OpenAiUsage {
|
|
83
|
+
promptTokens?: number;
|
|
84
|
+
completionTokens?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build a non-streaming `chat.completion` response. */
|
|
88
|
+
export function completionResponse(
|
|
89
|
+
content: string,
|
|
90
|
+
model: string,
|
|
91
|
+
usage?: OpenAiUsage,
|
|
92
|
+
): Response {
|
|
93
|
+
const promptTokens = usage?.promptTokens ?? 0;
|
|
94
|
+
const completionTokens = usage?.completionTokens ?? 0;
|
|
95
|
+
return new Response(JSON.stringify({
|
|
96
|
+
id: `chatcmpl-${Date.now()}`,
|
|
97
|
+
object: "chat.completion",
|
|
98
|
+
created: Math.floor(Date.now() / 1000),
|
|
99
|
+
model,
|
|
100
|
+
choices: [{
|
|
101
|
+
index: 0,
|
|
102
|
+
message: { role: "assistant", content },
|
|
103
|
+
finish_reason: "stop",
|
|
104
|
+
}],
|
|
105
|
+
usage: {
|
|
106
|
+
prompt_tokens: promptTokens,
|
|
107
|
+
completion_tokens: completionTokens,
|
|
108
|
+
total_tokens: promptTokens + completionTokens,
|
|
109
|
+
},
|
|
110
|
+
}), { status: 200, headers: JSON_HEADERS });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Emits `chat.completion.chunk` frames for one streamed response. */
|
|
114
|
+
export class ChunkWriter {
|
|
115
|
+
private readonly encoder = new TextEncoder();
|
|
116
|
+
private readonly id = `chatcmpl-${Date.now()}`;
|
|
117
|
+
private readonly created = Math.floor(Date.now() / 1000);
|
|
118
|
+
|
|
119
|
+
constructor(
|
|
120
|
+
private readonly controller: ReadableStreamDefaultController<Uint8Array>,
|
|
121
|
+
private readonly model: string,
|
|
122
|
+
) {}
|
|
123
|
+
|
|
124
|
+
/** One SSE frame carrying a delta (and optionally the finish reason). */
|
|
125
|
+
send(delta: Record<string, unknown>, finishReason: string | null = null): void {
|
|
126
|
+
const payload = {
|
|
127
|
+
id: this.id, object: "chat.completion.chunk", created: this.created, model: this.model,
|
|
128
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
129
|
+
};
|
|
130
|
+
this.controller.enqueue(this.encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** OpenAI clients treat the role-only first chunk as the start of the message. */
|
|
134
|
+
open(): void {
|
|
135
|
+
this.send({ role: "assistant", content: "" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
text(content: string): void {
|
|
139
|
+
if (content) this.send({ content });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Terminal frame plus the `[DONE]` sentinel every OpenAI client waits for. */
|
|
143
|
+
close(finishReason = "stop"): void {
|
|
144
|
+
this.send({}, finishReason);
|
|
145
|
+
this.controller.enqueue(this.encoder.encode("data: [DONE]\n\n"));
|
|
146
|
+
this.controller.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* controls, which is what let the two drift apart. The account cards are the same component
|
|
7
7
|
* the Settings pane renders — passing no action callbacks is what makes them read-only, so
|
|
8
8
|
* there is one card implementation rather than a display twin.
|
|
9
|
+
*
|
|
10
|
+
* Accounts sit in a row that scrolls sideways, not a vertical stack: this panel exists to
|
|
11
|
+
* compare them, and stacked in a 350px strip that meant scrolling past one account to see
|
|
12
|
+
* the next. Fullscreen lays the same cards out as a grid when there are too many to fit.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import {
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { Activity, ExternalLink, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
|
|
12
17
|
import type { UsageInfo } from "../../../types/chat";
|
|
13
18
|
import { openSettings } from "@/components/settings/open-settings";
|
|
14
19
|
import { AccountCard } from "@/components/settings/accounts/account-card";
|
|
@@ -61,6 +66,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
61
66
|
// Fetching is gated on visibility: the panel is collapsed most of the time, and its
|
|
62
67
|
// usage endpoint is the expensive one.
|
|
63
68
|
const { usages, accounts, activeAccountId, initialLoading, refreshing, flashIds, reload } = useAccountsData(visible);
|
|
69
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
64
70
|
|
|
65
71
|
if (!visible) return null;
|
|
66
72
|
|
|
@@ -68,8 +74,19 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
68
74
|
const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
|
|
69
75
|
const hasPerAccountUsage = usages.length > 0;
|
|
70
76
|
|
|
77
|
+
// Roughly square, so the cards fill the viewport instead of leaving a long empty column.
|
|
78
|
+
const fsCount = usages.length || 1;
|
|
79
|
+
const fsCols = Math.ceil(Math.sqrt(fsCount));
|
|
80
|
+
const fsRows = Math.ceil(fsCount / fsCols);
|
|
81
|
+
|
|
71
82
|
return (
|
|
72
|
-
<div
|
|
83
|
+
<div
|
|
84
|
+
className={`relative border-b border-border bg-surface px-3 py-2.5 ${
|
|
85
|
+
isFullscreen
|
|
86
|
+
? "fixed inset-0 z-50 flex flex-col gap-2.5 overflow-hidden"
|
|
87
|
+
: "space-y-2.5 max-h-[350px] overflow-y-auto"
|
|
88
|
+
}`}
|
|
89
|
+
>
|
|
73
90
|
<div className="flex items-center justify-between shrink-0">
|
|
74
91
|
<div className="flex items-center gap-2">
|
|
75
92
|
<span className="text-xs font-semibold text-text-primary">Usage</span>
|
|
@@ -85,6 +102,16 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
85
102
|
>
|
|
86
103
|
Manage accounts <ExternalLink className="size-3" />
|
|
87
104
|
</button>
|
|
105
|
+
{hasPerAccountUsage && (
|
|
106
|
+
<button
|
|
107
|
+
onClick={() => setIsFullscreen((v) => !v)}
|
|
108
|
+
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
109
|
+
title={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
|
|
110
|
+
aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
|
|
111
|
+
>
|
|
112
|
+
{isFullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
88
115
|
{onReload && (
|
|
89
116
|
<button
|
|
90
117
|
onClick={() => { onReload(); void reload(); }}
|
|
@@ -97,7 +124,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
97
124
|
</button>
|
|
98
125
|
)}
|
|
99
126
|
<button
|
|
100
|
-
onClick={onClose}
|
|
127
|
+
onClick={() => { setIsFullscreen(false); onClose(); }}
|
|
101
128
|
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
102
129
|
aria-label="Close usage panel"
|
|
103
130
|
>
|
|
@@ -107,7 +134,16 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
107
134
|
</div>
|
|
108
135
|
|
|
109
136
|
{hasPerAccountUsage || initialLoading ? (
|
|
110
|
-
<div
|
|
137
|
+
<div
|
|
138
|
+
className={isFullscreen
|
|
139
|
+
? "flex-1 min-h-0 grid gap-2 overflow-hidden"
|
|
140
|
+
// Same classes as AccountCardRow, with the panel's wider padding to clear.
|
|
141
|
+
: "flex gap-2 overflow-x-auto pb-1 -mx-3 px-3 snap-x snap-mandatory scrollbar-thin"}
|
|
142
|
+
style={isFullscreen ? {
|
|
143
|
+
gridTemplateColumns: `repeat(${fsCols}, minmax(0, 1fr))`,
|
|
144
|
+
gridTemplateRows: `repeat(${fsRows}, minmax(0, 1fr))`,
|
|
145
|
+
} : undefined}
|
|
146
|
+
>
|
|
111
147
|
{initialLoading ? (
|
|
112
148
|
<p className="text-[10px] text-text-subtle">Loading...</p>
|
|
113
149
|
) : (
|
|
@@ -118,6 +154,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
118
154
|
isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
|
|
119
155
|
accountInfo={accountMap.get(entry.accountId)}
|
|
120
156
|
flash={flashIds.has(entry.accountId)}
|
|
157
|
+
layout={isFullscreen ? "grid" : "strip"}
|
|
121
158
|
/>
|
|
122
159
|
))
|
|
123
160
|
)}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One account
|
|
3
|
-
*
|
|
2
|
+
* One account: name, badges, per-account controls, its rate-limit buckets, and a footer of
|
|
3
|
+
* token facts.
|
|
4
|
+
*
|
|
5
|
+
* Three layouts, because the two callers want opposite things. Settings manages accounts one
|
|
6
|
+
* at a time, so `list` gives each card the full width. The chat panel is for comparing them
|
|
7
|
+
* at a glance, so `strip` makes fixed-width cards that scroll sideways and `grid` fills a
|
|
8
|
+
* cell of the fullscreen view — stacked vertically, comparing two accounts meant scrolling
|
|
9
|
+
* past the one above.
|
|
4
10
|
*
|
|
5
11
|
* An expired account (past `expiresAt` AND no refresh token) is dimmed and loses every
|
|
6
12
|
* control except delete — toggling or exporting a token the server can no longer renew only
|
|
@@ -25,10 +31,19 @@ export interface AccountCardProps {
|
|
|
25
31
|
onViewProfile?: (profile: OAuthProfileData, accountId: string) => void;
|
|
26
32
|
/** Brief highlight when this account's usage numbers just changed. */
|
|
27
33
|
flash?: boolean;
|
|
34
|
+
/** `list` fills the width, `strip` is a fixed-width card in a sideways scroller, `grid`
|
|
35
|
+
* fills a cell of the fullscreen grid. */
|
|
36
|
+
layout?: "list" | "strip" | "grid";
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
// Fixed widths so a row scrolls instead of squeezing. Two of them: a read-only card holds a
|
|
40
|
+
// name and its bars, but a card that also carries view/export/toggle/delete needs room for
|
|
41
|
+
// four controls beside the name, and at the read-only width that header wraps.
|
|
42
|
+
const STRIP_WIDTH = { readOnly: "min-w-[220px]", withActions: "min-w-[300px]" } as const;
|
|
43
|
+
|
|
30
44
|
export function AccountCard({
|
|
31
45
|
entry, isActive, accountInfo, onToggle, toggling, onDelete, onExport, onViewProfile, flash,
|
|
46
|
+
layout = "list",
|
|
32
47
|
}: AccountCardProps) {
|
|
33
48
|
const { usage } = entry;
|
|
34
49
|
const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
|
|
@@ -38,12 +53,21 @@ export function AccountCard({
|
|
|
38
53
|
&& accountInfo.expiresAt < Math.floor(Date.now() / 1000)
|
|
39
54
|
);
|
|
40
55
|
const ts = tokenStatus(accountInfo);
|
|
56
|
+
const hasActions = Boolean(onToggle || onDelete || onExport || onViewProfile);
|
|
57
|
+
|
|
58
|
+
const layoutClass = layout === "list"
|
|
59
|
+
? ""
|
|
60
|
+
: layout === "grid"
|
|
61
|
+
// The cell owns the height, so the card spreads its rows into it.
|
|
62
|
+
? "flex flex-col justify-evenly overflow-hidden min-h-0"
|
|
63
|
+
: `shrink-0 snap-start ${hasActions ? STRIP_WIDTH.withActions : STRIP_WIDTH.readOnly}`;
|
|
41
64
|
|
|
42
65
|
return (
|
|
43
66
|
<AccountCardShell
|
|
44
67
|
active={isActive}
|
|
45
68
|
flash={flash}
|
|
46
|
-
|
|
69
|
+
dense={layout !== "list"}
|
|
70
|
+
className={[layoutClass, isExpired ? "opacity-50" : ""].filter(Boolean).join(" ") || undefined}
|
|
47
71
|
data-testid="account-card"
|
|
48
72
|
data-account-id={entry.accountId}
|
|
49
73
|
>
|
|
@@ -65,23 +65,43 @@ export function AccountsPaneMessage({ message, onDismiss }: {
|
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The row accounts are laid out in: a sideways scroller, one card per account.
|
|
70
|
+
*
|
|
71
|
+
* Horizontal everywhere — the chat panel and both settings panes — because the point of the
|
|
72
|
+
* list is comparing accounts, and stacked vertically you scroll past one to reach the next.
|
|
73
|
+
* Shared so the three callers cannot end up scrolling in different directions.
|
|
74
|
+
*/
|
|
75
|
+
export function AccountCardRow({ children }: { children: React.ReactNode }) {
|
|
76
|
+
return (
|
|
77
|
+
// Negative margin then padding: cards scroll edge to edge while still clearing the
|
|
78
|
+
// pane's own padding at rest. Snap so a swipe lands on a card rather than between two.
|
|
79
|
+
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 snap-x snap-mandatory scrollbar-thin">
|
|
80
|
+
{children}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
/**
|
|
69
86
|
* The card one account is drawn in.
|
|
70
87
|
*
|
|
71
88
|
* Shared so a Claude row and a Codex row are the same object on screen even though what goes
|
|
72
89
|
* inside them differs.
|
|
73
90
|
*/
|
|
74
|
-
export function AccountCardShell({ active, flash, children, ...rest }: {
|
|
91
|
+
export function AccountCardShell({ active, flash, dense, children, ...rest }: {
|
|
75
92
|
active?: boolean;
|
|
76
93
|
/** Brief highlight when this account's numbers just changed. */
|
|
77
94
|
flash?: boolean;
|
|
95
|
+
/** Tighter padding, for a card that has to fit a fixed narrow width. */
|
|
96
|
+
dense?: boolean;
|
|
78
97
|
children: React.ReactNode;
|
|
79
98
|
} & React.HTMLAttributes<HTMLDivElement>) {
|
|
80
99
|
return (
|
|
81
100
|
<div
|
|
82
101
|
{...rest}
|
|
83
102
|
className={[
|
|
84
|
-
"rounded-md border
|
|
103
|
+
"rounded-md border transition-colors duration-500",
|
|
104
|
+
dense ? "p-2.5 space-y-1.5" : "p-3 space-y-2",
|
|
85
105
|
flash ? "bg-primary/10 border-primary/40" : "",
|
|
86
106
|
active ? "border-primary/30 bg-primary/5" : "border-border/50",
|
|
87
107
|
rest.className ?? "",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { useState } from "react";
|
|
11
11
|
import { Download, KeyRound, Loader2, Plus, Settings, Upload, FlaskConical } from "lucide-react";
|
|
12
12
|
import { Button } from "@/components/ui/button";
|
|
13
|
-
import { AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
|
|
13
|
+
import { AccountCardRow, AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
|
|
14
14
|
import { patchAccount, deleteAccount, type OAuthProfileData } from "../../../lib/api-settings";
|
|
15
15
|
import { AccountProfilePanel } from "./account-profile-panel";
|
|
16
16
|
import { AccountDeleteConfirm } from "./account-delete-confirm";
|
|
@@ -117,9 +117,10 @@ export function ClaudeAccountsSection() {
|
|
|
117
117
|
<p className="text-xs text-muted-foreground">Add one to start using PPM.</p>
|
|
118
118
|
</div>
|
|
119
119
|
) : (
|
|
120
|
-
<
|
|
120
|
+
<AccountCardRow>
|
|
121
121
|
{usages.map((entry) => (
|
|
122
122
|
<AccountCard
|
|
123
|
+
layout="strip"
|
|
123
124
|
key={entry.accountId}
|
|
124
125
|
entry={entry}
|
|
125
126
|
isActive={entry.accountId === activeAccountId}
|
|
@@ -132,7 +133,7 @@ export function ClaudeAccountsSection() {
|
|
|
132
133
|
flash={flashIds.has(entry.accountId)}
|
|
133
134
|
/>
|
|
134
135
|
))}
|
|
135
|
-
</
|
|
136
|
+
</AccountCardRow>
|
|
136
137
|
)}
|
|
137
138
|
</section>
|
|
138
139
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { useState } from "react";
|
|
10
10
|
import { Download, KeyRound, Loader2, Plus, Settings, Trash2, Upload } from "lucide-react";
|
|
11
11
|
import { Button } from "@/components/ui/button";
|
|
12
|
-
import { AccountCardShell, AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
|
|
12
|
+
import { AccountCardRow, AccountCardShell, AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
|
|
13
13
|
import { AccountUsageBar } from "./account-bucket-row";
|
|
14
14
|
import { CodexAddAccountDialog } from "./codex-add-account-dialog";
|
|
15
15
|
import { CodexBackupDialog } from "./codex-backup-dialog";
|
|
@@ -70,11 +70,18 @@ export function CodexAccountsSection() {
|
|
|
70
70
|
</p>
|
|
71
71
|
</div>
|
|
72
72
|
) : (
|
|
73
|
-
<
|
|
73
|
+
<AccountCardRow>
|
|
74
74
|
{c.accounts.map((a) => {
|
|
75
75
|
const u = c.usages[a.id] ?? {};
|
|
76
76
|
return (
|
|
77
|
-
<AccountCardShell
|
|
77
|
+
<AccountCardShell
|
|
78
|
+
key={a.id}
|
|
79
|
+
dense
|
|
80
|
+
// Matches the Claude card that also carries a control beside the name.
|
|
81
|
+
className="min-w-[300px] shrink-0 snap-start"
|
|
82
|
+
data-testid="account-card"
|
|
83
|
+
data-account-id={a.id}
|
|
84
|
+
>
|
|
78
85
|
<div className="flex items-center gap-2">
|
|
79
86
|
<span className="text-sm font-medium truncate flex-1 min-w-0">{a.label}</span>
|
|
80
87
|
<span className="text-[10px] uppercase tracking-wide text-text-subtle border border-border rounded px-1 shrink-0">
|
|
@@ -98,7 +105,7 @@ export function CodexAccountsSection() {
|
|
|
98
105
|
</AccountCardShell>
|
|
99
106
|
);
|
|
100
107
|
})}
|
|
101
|
-
</
|
|
108
|
+
</AccountCardRow>
|
|
102
109
|
)}
|
|
103
110
|
</section>
|
|
104
111
|
|