@hienlh/ppm 0.8.58 → 0.9.0-beta.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/CHANGELOG.md +34 -0
- package/dist/web/assets/chat-tab-BDyjEN8p.js +7 -0
- package/dist/web/assets/{code-editor-u6bm6bdq.js → code-editor-BmFI-Khj.js} +1 -1
- package/dist/web/assets/{database-viewer-BgPBW1bJ.js → database-viewer-Cb7tqJqX.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cho-kjse.js → diff-viewer-D_f9S4Ya.js} +1 -1
- package/dist/web/assets/{git-graph-CktRdFwt.js → git-graph-Co3a8y4i.js} +1 -1
- package/dist/web/assets/index-BAioKo_2.css +2 -0
- package/dist/web/assets/{index-CpOYx0qg.js → index-CqMDTnLp.js} +13 -7
- package/dist/web/assets/keybindings-store-CulLCWPX.js +1 -0
- package/dist/web/assets/{markdown-renderer-3_CTktzg.js → markdown-renderer-xipSjvIr.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CH0JfEQ9.js → postgres-viewer-p5tz14oN.js} +1 -1
- package/dist/web/assets/{settings-tab-BI0n39LJ.js → settings-tab-CCz5ftre.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DJyT7YZg.js → sqlite-viewer-vF9L6tfk.js} +1 -1
- package/dist/web/assets/{terminal-tab-OqCohyF0.js → terminal-tab-XbV1JFTB.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/snapshot-state.md +1526 -0
- package/src/index.ts +0 -0
- package/src/server/index.ts +3 -15
- package/src/server/routes/proxy.ts +46 -53
- package/src/server/ws/chat.ts +184 -139
- package/src/services/account-selector.service.ts +8 -6
- package/src/services/account.service.ts +1 -0
- package/src/services/claude-usage.service.ts +10 -4
- package/src/services/proxy.service.ts +4 -19
- package/src/types/api.ts +9 -1
- package/src/web/components/chat/chat-tab.tsx +14 -5
- package/src/web/components/chat/message-list.tsx +15 -12
- package/src/web/components/settings/proxy-settings-section.tsx +40 -42
- package/src/web/hooks/use-chat.ts +196 -203
- package/test-tokens.mjs +212 -0
- package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
- package/dist/web/assets/chat-tab-cawT08fh.js +0 -7
- package/dist/web/assets/index-WKLuYsBY.css +0 -2
- package/dist/web/assets/keybindings-store-vOnSm10D.js +0 -1
|
@@ -60,12 +60,6 @@ class ProxyService {
|
|
|
60
60
|
|
|
61
61
|
// Ensure token is fresh for OAuth accounts
|
|
62
62
|
let token = account.accessToken;
|
|
63
|
-
if (!token) {
|
|
64
|
-
return new Response(
|
|
65
|
-
JSON.stringify({ type: "error", error: { type: "authentication_error", message: "Account has no access token (decryption may have failed)" } }),
|
|
66
|
-
{ status: 401, headers: { "Content-Type": "application/json" } },
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
63
|
if (token.startsWith("sk-ant-oat")) {
|
|
70
64
|
const fresh = await accountService.ensureFreshToken(account.id);
|
|
71
65
|
if (fresh) token = fresh.accessToken;
|
|
@@ -114,22 +108,14 @@ class ProxyService {
|
|
|
114
108
|
} else if (upstream.status === 401) {
|
|
115
109
|
accountSelector.onAuthError(account.id);
|
|
116
110
|
console.log(`[proxy] 401 from Anthropic — account ${account.email ?? account.id} auth error`);
|
|
117
|
-
} else if (upstream.status >= 400) {
|
|
118
|
-
console.log(`[proxy] ${upstream.status} from Anthropic — account ${account.email ?? account.id} (OAuth=${token.startsWith("sk-ant-oat")})`);
|
|
119
111
|
} else if (upstream.status >= 200 && upstream.status < 300) {
|
|
120
112
|
accountSelector.onSuccess(account.id);
|
|
121
113
|
}
|
|
122
114
|
|
|
123
115
|
// Stream response back as-is (preserves SSE for streaming)
|
|
124
116
|
const responseHeaders = new Headers();
|
|
125
|
-
// Forward
|
|
126
|
-
for (const key of [
|
|
127
|
-
"content-type", "x-request-id", "request-id",
|
|
128
|
-
"anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining",
|
|
129
|
-
"anthropic-ratelimit-requests-reset", "anthropic-ratelimit-tokens-limit",
|
|
130
|
-
"anthropic-ratelimit-tokens-remaining", "anthropic-ratelimit-tokens-reset",
|
|
131
|
-
"retry-after",
|
|
132
|
-
]) {
|
|
117
|
+
// Forward key response headers
|
|
118
|
+
for (const key of ["content-type", "x-request-id", "request-id"]) {
|
|
133
119
|
const val = upstream.headers.get(key);
|
|
134
120
|
if (val) responseHeaders.set(key, val);
|
|
135
121
|
}
|
|
@@ -141,10 +127,9 @@ class ProxyService {
|
|
|
141
127
|
headers: responseHeaders,
|
|
142
128
|
});
|
|
143
129
|
} catch (e) {
|
|
144
|
-
|
|
145
|
-
console.error(`[proxy] Error forwarding to Anthropic:`, msg);
|
|
130
|
+
console.error(`[proxy] Error forwarding to Anthropic:`, (e as Error).message);
|
|
146
131
|
return new Response(
|
|
147
|
-
JSON.stringify({ type: "error", error: { type: "api_error", message:
|
|
132
|
+
JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
|
|
148
133
|
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
149
134
|
);
|
|
150
135
|
}
|
package/src/types/api.ts
CHANGED
|
@@ -28,6 +28,9 @@ export type ChatWsClientMessage =
|
|
|
28
28
|
| { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown }
|
|
29
29
|
| { type: "ready" };
|
|
30
30
|
|
|
31
|
+
/** Session phase for the 5-state machine (BE-owned) */
|
|
32
|
+
export type SessionPhase = "initializing" | "connecting" | "thinking" | "streaming" | "idle";
|
|
33
|
+
|
|
31
34
|
export type ChatWsServerMessage =
|
|
32
35
|
| { type: "text"; content: string; parentToolUseId?: string }
|
|
33
36
|
| { type: "thinking"; content: string; parentToolUseId?: string }
|
|
@@ -36,4 +39,9 @@ export type ChatWsServerMessage =
|
|
|
36
39
|
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
37
40
|
| { type: "done"; sessionId: string; contextWindowPct?: number }
|
|
38
41
|
| { type: "error"; message: string }
|
|
39
|
-
| { type: "account_info"; accountId: string; accountLabel: string }
|
|
42
|
+
| { type: "account_info"; accountId: string; accountLabel: string }
|
|
43
|
+
| { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
|
|
44
|
+
| { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null }
|
|
45
|
+
| { type: "turn_events"; events: unknown[] }
|
|
46
|
+
| { type: "title_updated"; title: string }
|
|
47
|
+
| { type: "ping" };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
-
import { Upload, X } from "lucide-react";
|
|
2
|
+
import { Loader2, Upload, X } from "lucide-react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
4
|
import { useChat } from "@/hooks/use-chat";
|
|
5
5
|
import { useUsage } from "@/hooks/use-usage";
|
|
@@ -83,9 +83,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
83
83
|
messages,
|
|
84
84
|
messagesLoading,
|
|
85
85
|
isStreaming,
|
|
86
|
-
|
|
86
|
+
phase,
|
|
87
|
+
isReconnecting,
|
|
87
88
|
connectingElapsed,
|
|
88
|
-
thinkingWarningThreshold,
|
|
89
89
|
pendingApproval,
|
|
90
90
|
contextWindowPct,
|
|
91
91
|
sessionTitle,
|
|
@@ -311,6 +311,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
311
311
|
</div>
|
|
312
312
|
)}
|
|
313
313
|
|
|
314
|
+
{/* Reconnect overlay */}
|
|
315
|
+
{isReconnecting && (
|
|
316
|
+
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
|
317
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
318
|
+
<Loader2 className="size-4 animate-spin" />
|
|
319
|
+
<span>Reconnecting...</span>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
314
324
|
{/* Messages */}
|
|
315
325
|
<MessageList
|
|
316
326
|
messages={messages}
|
|
@@ -318,9 +328,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
318
328
|
pendingApproval={pendingApproval}
|
|
319
329
|
onApprovalResponse={respondToApproval}
|
|
320
330
|
isStreaming={isStreaming}
|
|
321
|
-
|
|
331
|
+
phase={phase}
|
|
322
332
|
connectingElapsed={connectingElapsed}
|
|
323
|
-
thinkingWarningThreshold={thinkingWarningThreshold}
|
|
324
333
|
projectName={projectName}
|
|
325
334
|
onFork={!isStreaming ? handleFork : undefined}
|
|
326
335
|
/>
|
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|
|
2
2
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
|
3
3
|
import { getAuthToken } from "@/lib/api-client";
|
|
4
4
|
import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
5
|
-
import type {
|
|
5
|
+
import type { SessionPhase } from "../../../types/api";
|
|
6
6
|
import { ToolCard } from "./tool-cards";
|
|
7
7
|
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
8
8
|
import { cn, basename } from "@/lib/utils";
|
|
@@ -39,9 +39,8 @@ interface MessageListProps {
|
|
|
39
39
|
pendingApproval: { requestId: string; tool: string; input: unknown } | null;
|
|
40
40
|
onApprovalResponse: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
41
41
|
isStreaming: boolean;
|
|
42
|
-
|
|
42
|
+
phase?: SessionPhase;
|
|
43
43
|
connectingElapsed?: number;
|
|
44
|
-
thinkingWarningThreshold?: number;
|
|
45
44
|
projectName?: string;
|
|
46
45
|
/** Called when user clicks Fork/Rewind — opens new forked chat tab */
|
|
47
46
|
onFork?: (userMessage: string) => void;
|
|
@@ -53,9 +52,8 @@ export function MessageList({
|
|
|
53
52
|
pendingApproval,
|
|
54
53
|
onApprovalResponse,
|
|
55
54
|
isStreaming,
|
|
56
|
-
|
|
55
|
+
phase,
|
|
57
56
|
connectingElapsed,
|
|
58
|
-
thinkingWarningThreshold,
|
|
59
57
|
projectName,
|
|
60
58
|
onFork,
|
|
61
59
|
}: MessageListProps) {
|
|
@@ -108,7 +106,7 @@ export function MessageList({
|
|
|
108
106
|
: <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
|
|
109
107
|
)}
|
|
110
108
|
|
|
111
|
-
{isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]}
|
|
109
|
+
{isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} phase={phase} elapsed={connectingElapsed} />}
|
|
112
110
|
</StickToBottom.Content>
|
|
113
111
|
<ScrollToBottomButton />
|
|
114
112
|
</StickToBottom>
|
|
@@ -751,14 +749,13 @@ function StreamingText({ content, animate: isStreaming, projectName }: { content
|
|
|
751
749
|
* - After tool: "Processing..."
|
|
752
750
|
* - Text streaming: hidden
|
|
753
751
|
*/
|
|
754
|
-
function ThinkingIndicator({ lastMessage,
|
|
755
|
-
// Show
|
|
752
|
+
function ThinkingIndicator({ lastMessage, phase, elapsed }: { lastMessage?: ChatMessage; phase?: SessionPhase; elapsed?: number }) {
|
|
753
|
+
// Show indicator when:
|
|
756
754
|
// 1. No assistant message yet (waiting for first response)
|
|
757
|
-
// 2. Last event is
|
|
755
|
+
// 2. Last event is tool_result (Claude thinking after tool execution)
|
|
758
756
|
// Hide when text is actively streaming (text itself is the indicator)
|
|
759
757
|
|
|
760
758
|
const isWaiting = !lastMessage || lastMessage.role !== "assistant";
|
|
761
|
-
// Show Thinking only after tool_result (tool finished), not tool_use (tool still running)
|
|
762
759
|
const isAfterTool = (() => {
|
|
763
760
|
if (!lastMessage?.events?.length) return false;
|
|
764
761
|
const last = lastMessage.events[lastMessage.events.length - 1]!;
|
|
@@ -767,13 +764,19 @@ function ThinkingIndicator({ lastMessage, streamingStatus, elapsed, warningThres
|
|
|
767
764
|
|
|
768
765
|
if (!isWaiting && !isAfterTool) return null;
|
|
769
766
|
|
|
770
|
-
const
|
|
767
|
+
const label = phase === "initializing" ? "Initializing"
|
|
768
|
+
: phase === "connecting" ? "Connecting"
|
|
769
|
+
: phase === "thinking" ? "Thinking"
|
|
770
|
+
: "Processing";
|
|
771
|
+
|
|
772
|
+
const isLong = phase === "connecting" && (elapsed ?? 0) >= 30;
|
|
773
|
+
|
|
771
774
|
return (
|
|
772
775
|
<div className="flex flex-col gap-1 text-sm">
|
|
773
776
|
<div className="flex items-center gap-2 text-text-subtle">
|
|
774
777
|
<Loader2 className="size-3 animate-spin" />
|
|
775
778
|
<span>
|
|
776
|
-
|
|
779
|
+
{label}
|
|
777
780
|
{isWaiting && (elapsed ?? 0) > 0 && <span className="text-text-subtle/60">... ({elapsed}s)</span>}
|
|
778
781
|
</span>
|
|
779
782
|
</div>
|
|
@@ -127,78 +127,76 @@ export function ProxySettingsSection() {
|
|
|
127
127
|
<div className="space-y-2 rounded-md border p-3 bg-muted/30">
|
|
128
128
|
<h4 className="text-[11px] font-medium">Connection Info</h4>
|
|
129
129
|
|
|
130
|
-
{/*
|
|
130
|
+
{/* Local endpoint */}
|
|
131
131
|
<div className="space-y-1">
|
|
132
|
-
<Label className="text-[10px] text-muted-foreground">
|
|
132
|
+
<Label className="text-[10px] text-muted-foreground">Local Endpoint</Label>
|
|
133
133
|
<div className="flex gap-1.5 items-center">
|
|
134
134
|
<code className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded flex-1 truncate">
|
|
135
|
-
{
|
|
136
|
-
? `${settings.tunnelUrl}/proxy`
|
|
137
|
-
: `${window.location.origin}/proxy`}
|
|
135
|
+
{`${window.location.origin}/proxy/v1/messages`}
|
|
138
136
|
</code>
|
|
139
137
|
<Button
|
|
140
138
|
variant="ghost"
|
|
141
139
|
size="sm"
|
|
142
140
|
className="h-6 px-1.5 cursor-pointer shrink-0"
|
|
143
|
-
onClick={() => copyToClipboard(
|
|
144
|
-
hasTunnel && settings.tunnelUrl
|
|
145
|
-
? `${settings.tunnelUrl}/proxy`
|
|
146
|
-
: `${window.location.origin}/proxy`,
|
|
147
|
-
"baseurl",
|
|
148
|
-
)}
|
|
141
|
+
onClick={() => copyToClipboard(`${window.location.origin}/proxy/v1/messages`, "local")}
|
|
149
142
|
>
|
|
150
|
-
{copied === "
|
|
143
|
+
{copied === "local" ? "Copied!" : <Copy className="size-3" />}
|
|
151
144
|
</Button>
|
|
152
145
|
</div>
|
|
153
146
|
</div>
|
|
154
147
|
|
|
148
|
+
{/* Tunnel endpoint */}
|
|
149
|
+
{hasTunnel && settings.proxyEndpoint && (
|
|
150
|
+
<div className="space-y-1">
|
|
151
|
+
<Label className="text-[10px] text-muted-foreground">Public Endpoint (Tunnel)</Label>
|
|
152
|
+
<div className="flex gap-1.5 items-center">
|
|
153
|
+
<code className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded flex-1 truncate">
|
|
154
|
+
{settings.proxyEndpoint}
|
|
155
|
+
</code>
|
|
156
|
+
<Button
|
|
157
|
+
variant="ghost"
|
|
158
|
+
size="sm"
|
|
159
|
+
className="h-6 px-1.5 cursor-pointer shrink-0"
|
|
160
|
+
onClick={() => copyToClipboard(settings.proxyEndpoint!, "tunnel")}
|
|
161
|
+
>
|
|
162
|
+
{copied === "tunnel" ? "Copied!" : <Copy className="size-3" />}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
155
168
|
{!hasTunnel && (
|
|
156
169
|
<p className="text-[10px] text-muted-foreground">
|
|
157
170
|
Start a Cloudflare tunnel (Share) to get a public URL.
|
|
158
171
|
</p>
|
|
159
172
|
)}
|
|
160
173
|
|
|
161
|
-
{/*
|
|
162
|
-
<div className="space-y-1 pt-1">
|
|
163
|
-
<Label className="text-[10px] text-muted-foreground">Claude Code CLI</Label>
|
|
164
|
-
<div className="relative">
|
|
165
|
-
<pre className="text-[9px] font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre">
|
|
166
|
-
{`ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"} \\
|
|
167
|
-
ANTHROPIC_API_KEY=${settings.authKey} \\
|
|
168
|
-
claude`}
|
|
169
|
-
</pre>
|
|
170
|
-
<Button
|
|
171
|
-
variant="ghost"
|
|
172
|
-
size="sm"
|
|
173
|
-
className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
|
|
174
|
-
onClick={() => copyToClipboard(
|
|
175
|
-
`ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"} ANTHROPIC_API_KEY=${settings.authKey} claude`,
|
|
176
|
-
"claude-cli",
|
|
177
|
-
)}
|
|
178
|
-
>
|
|
179
|
-
{copied === "claude-cli" ? "Copied!" : <Copy className="size-2.5" />}
|
|
180
|
-
</Button>
|
|
181
|
-
</div>
|
|
182
|
-
</div>
|
|
183
|
-
|
|
184
|
-
{/* Generic env vars */}
|
|
174
|
+
{/* Usage example */}
|
|
185
175
|
<div className="space-y-1 pt-1">
|
|
186
|
-
<Label className="text-[10px] text-muted-foreground">
|
|
176
|
+
<Label className="text-[10px] text-muted-foreground">Usage Example</Label>
|
|
187
177
|
<div className="relative">
|
|
188
178
|
<pre className="text-[9px] font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre">
|
|
189
|
-
{
|
|
190
|
-
|
|
179
|
+
{`# Set as base URL in your tool
|
|
180
|
+
ANTHROPIC_BASE_URL=${hasTunnel && settings.proxyEndpoint ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}
|
|
181
|
+
ANTHROPIC_API_KEY=${settings.authKey}
|
|
182
|
+
|
|
183
|
+
# Or use curl
|
|
184
|
+
curl ${hasTunnel && settings.proxyEndpoint ? settings.proxyEndpoint : window.location.origin + "/proxy/v1/messages"} \\
|
|
185
|
+
-H "x-api-key: ${settings.authKey}" \\
|
|
186
|
+
-H "content-type: application/json" \\
|
|
187
|
+
-H "anthropic-version: 2023-06-01" \\
|
|
188
|
+
-d '{"model":"claude-sonnet-4-6","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}'`}
|
|
191
189
|
</pre>
|
|
192
190
|
<Button
|
|
193
191
|
variant="ghost"
|
|
194
192
|
size="sm"
|
|
195
193
|
className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
|
|
196
194
|
onClick={() => copyToClipboard(
|
|
197
|
-
`
|
|
198
|
-
"
|
|
195
|
+
`ANTHROPIC_BASE_URL=${hasTunnel ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}\nANTHROPIC_API_KEY=${settings.authKey}`,
|
|
196
|
+
"example",
|
|
199
197
|
)}
|
|
200
198
|
>
|
|
201
|
-
{copied === "
|
|
199
|
+
{copied === "example" ? "Copied!" : <Copy className="size-2.5" />}
|
|
202
200
|
</Button>
|
|
203
201
|
</div>
|
|
204
202
|
</div>
|