Skip to content

Commit 7b4184f

Browse files
Junyi-99claude
andcommitted
Merge origin/main into staging
Resolve conflicts for staging→main sync (PR #170): - postcss/vite/package.json: keep staging's Tailwind 4 setup - model-selection/selection: keep main's model divider feature - api-key-settings: keep main's BYOK tooltip + labeled buttons - stream_v2.go: drop duplicated llmProvider block - footer/index.tsx: import Button from @heroui/react (no @heroui/button dep) - regenerated package-lock.json; build + lint pass Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EvF4FfL7fkGBbvhHLuAWN3
2 parents 2d7f692 + 527afed commit 7b4184f

8 files changed

Lines changed: 187 additions & 122 deletions

File tree

internal/api/chat/create_conversation_message_stream_v2.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -321,32 +321,6 @@ func (s *ChatServerV2) CreateConversationMessageStream(
321321
}
322322
}
323323

324-
// Usage is the same as ChatCompletion, just passing the stream parameter
325-
326-
if customModel == nil {
327-
// User did not specify API key for this model
328-
llmProvider = &models.LLMProviderConfig{
329-
APIKey: "",
330-
IsCustomModel: false,
331-
}
332-
} else {
333-
customModel.BaseUrl = strings.ToLower(customModel.BaseUrl)
334-
335-
if strings.Contains(customModel.BaseUrl, "paperdebugger.com") {
336-
customModel.BaseUrl = ""
337-
}
338-
if !strings.HasPrefix(customModel.BaseUrl, "https://") {
339-
customModel.BaseUrl = strings.Replace(customModel.BaseUrl, "http://", "", 1)
340-
customModel.BaseUrl = "https://" + customModel.BaseUrl
341-
}
342-
343-
llmProvider = &models.LLMProviderConfig{
344-
APIKey: customModel.APIKey,
345-
Endpoint: customModel.BaseUrl,
346-
IsCustomModel: true,
347-
}
348-
}
349-
350324
openaiChatHistory, inappChatHistory, _, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.UserID, conversation.ProjectID, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider, customModel)
351325
if err != nil {
352326
return s.sendStreamError(stream, err)

internal/libs/tex/latexpand.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,23 @@ import (
88
"paperdebugger/internal/libs/shared"
99
)
1010

11+
// commentRegex matches a LaTeX comment: an unescaped % and everything after it
12+
// until end of line. The leading group captures either start-of-line or a
13+
// non-backslash character, then consumes pairs of backslashes (\\) before %.
14+
// This generalizes to any run of N backslashes preceding %: if N is even
15+
// (including 0), every backslash pairs up as a literal-backslash escape and
16+
// the % is unescaped, so the comment is stripped; if N is odd, the final
17+
// backslash escapes the % itself, so the % (and the surrounding text) is
18+
// preserved.
19+
var commentRegex = regexp.MustCompile(`(^|[^\\])((?:\\\\)*)%.*$`)
20+
1121
func removeComments(text string) string {
1222
// Split into lines, trim each line and filter empty ones
1323
lines := strings.Split(text, "\n")
1424
var result []string
1525
for _, line := range lines {
1626
trimmed := strings.TrimSpace(line)
17-
commentRegex := regexp.MustCompile(`%.*$`)
18-
cleaned := commentRegex.ReplaceAllString(trimmed, "")
27+
cleaned := commentRegex.ReplaceAllString(trimmed, "$1$2")
1928
cleaned = strings.TrimSpace(cleaned)
2029
if len(cleaned) == 0 {
2130
continue

internal/libs/tex/latexpand_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@ Hello, world!
2020
\end{document}`, removeComments(input))
2121
}
2222

23+
func TestRemoveCommentsBackslashRunsBeforePercent(t *testing.T) {
24+
cases := []struct {
25+
name string
26+
input string
27+
want string
28+
}{
29+
{"1 backslash (odd) preserves %", `a\% keep`, `a\% keep`},
30+
{"2 backslashes (even) strips comment", `a\\% drop`, `a\\`},
31+
{"3 backslashes (odd) preserves %", `a\\\% keep`, `a\\\% keep`},
32+
{"4 backslashes (even) strips comment", `a\\\\% drop`, `a\\\\`},
33+
{"5 backslashes (odd) preserves %", `a\\\\\% keep`, `a\\\\\% keep`},
34+
{"3 backslashes at line start preserves %", `\\\% keep`, `\\\% keep`},
35+
{"4 backslashes at line start strips comment", `\\\\% drop`, `\\\\`},
36+
}
37+
for _, tc := range cases {
38+
t.Run(tc.name, func(t *testing.T) {
39+
assert.Equal(t, tc.want, removeComments(tc.input))
40+
})
41+
}
42+
}
43+
2344
func TestLatexpand(t *testing.T) {
2445
input := map[string]string{
2546
"main.tex": `

webapp/_webapp/src/libs/apiclient.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class ApiClient {
4444
}
4545

4646
updateBaseURL(baseURL: string, apiVersion: ApiVersion): void {
47-
this.axiosInstance.defaults.baseURL = `${baseURL}/_pd/api/${apiVersion}`;
47+
this.axiosInstance.defaults.baseURL = `${sanitizeEndpoint(baseURL)}/_pd/api/${apiVersion}`;
4848
switch (apiVersion) {
4949
case "v1":
5050
storage.setItem(API_VERSION_STORAGE_KEYS.v1, this.axiosInstance.defaults.baseURL);
@@ -272,6 +272,8 @@ const DEFAULT_ENDPOINT = `${process.env.PD_API_ENDPOINT || "http://localhost:300
272272
const LOCAL_STORAGE_KEY_V1 = "pd.devtool.endpoint";
273273
const LOCAL_STORAGE_KEY_V2 = "pd.devtool.endpoint.v2";
274274

275+
const sanitizeEndpoint = (url: string) => url.trim().replace(/\/+$/, "");
276+
275277
// Create apiclient instance with endpoint from storage or default
276278
export const getEndpointFromStorage = () => {
277279
let endpoint;
@@ -282,7 +284,7 @@ export const getEndpointFromStorage = () => {
282284
endpoint = DEFAULT_ENDPOINT;
283285
}
284286

285-
return endpoint.replace("/_pd/api/v1", "").replace("/_pd/api/v2", ""); // compatible with old endpoint
287+
return sanitizeEndpoint(endpoint.replace("/_pd/api/v1", "").replace("/_pd/api/v2", "")); // compatible with old endpoint
286288
};
287289

288290
/**

webapp/_webapp/src/views/chat/footer/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Button } from "@heroui/button";
1+
import { Button } from "@heroui/react";
22
import { useCallback, useMemo, useState } from "react";
33
import { Icon } from "@iconify/react";
44
import { useSelectionStore } from "@/stores/selection-store";

webapp/_webapp/src/views/chat/footer/toolbar/model-selection.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,41 @@ export function ModelSelection({ onSelectModel }: ModelSelectionProps) {
1212
const { models, currentModel, setModel } = useLanguageModels();
1313

1414
const items: SelectionItem<string>[] = useMemo(() => {
15-
return models.map((model) => ({
15+
const customModels = models.filter((m) => m.isCustom);
16+
const builtInModels = models.filter((m) => !m.isCustom);
17+
18+
const mapToItem = (model: (typeof models)[number]): SelectionItem<string> => ({
1619
title: model.name,
1720
subtitle: `${model.slug}${model.isCustom ? " (Custom)" : ""}`,
1821
value: model.slug,
1922
disabled: model.disabled,
2023
disabledReason: model.disabledReason,
2124
id: model.id ?? undefined,
2225
isCustom: model.isCustom,
23-
}));
26+
});
27+
28+
const customItems = customModels.map(mapToItem);
29+
const builtInItems = builtInModels.map(mapToItem);
30+
31+
if (customItems.length > 0 && builtInItems.length > 0) {
32+
return [
33+
...customItems,
34+
{
35+
title: "divider",
36+
value: "__divider__" as string,
37+
disabled: true,
38+
isDivider: true,
39+
},
40+
...builtInItems,
41+
];
42+
}
43+
44+
return [...customItems, ...builtInItems];
2445
}, [models]);
2546

2647
const onSelect = useCallback(
2748
(item: SelectionItem<string>) => {
28-
if (item.disabled) return;
49+
if (item.disabled || item.isDivider) return;
2950

3051
const selectedModel = item.isCustom
3152
? ((item.id ? models.find((m) => m.id === item.id) : undefined) ?? models.find((m) => m.slug === item.value))

webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx

Lines changed: 87 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type SelectionItem<T> = {
1414
disabledReason?: string;
1515
id?: string;
1616
isCustom?: boolean;
17+
isDivider?: boolean;
1718
};
1819

1920
type SelectionProps<T> = {
@@ -173,77 +174,99 @@ export function Selection<T>({ items, initialValue, onSelect, onClose }: Selecti
173174
heightCollapseRequired || minimalistMode ? "p-0 max-h-[100px]" : "p-2 max-h-[200px]",
174175
)}
175176
>
176-
{items?.map((item, idx) => (
177-
<div
178-
key={`${item.title}-${item.subtitle ?? ""}-${item.description ?? ""}`}
179-
className={cn(
180-
"prompt-selection-item w-full flex flex-col rounded-lg",
181-
item.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
182-
idx === selectedIdx && !item.disabled && "bg-gray-100 dark:!bg-default-200",
183-
heightCollapseRequired || minimalistMode ? "px-2 py-1" : "p-2",
184-
)}
185-
role="button"
186-
tabIndex={item.disabled ? -1 : 0}
187-
onClick={() => {
188-
if (item.disabled) return;
189-
googleAnalytics.fireEvent(user?.id, `select_${normalizeName(item.title)}`, {});
190-
onSelect?.(item);
191-
}}
192-
onKeyDown={(e) => {
193-
if (e.key === "Enter" || e.key === " ") {
194-
if (item.disabled) return;
195-
googleAnalytics.fireEvent(user?.id, `select_${normalizeName(item.title)}`, {});
196-
onSelect?.(item);
197-
}
198-
}}
199-
onMouseEnter={() => {
200-
if (!isKeyboardNavigation && !item.disabled) {
201-
setSelectedIdx(idx);
202-
}
203-
}}
204-
>
177+
{items?.map((item, idx) => {
178+
if (item.isDivider) {
179+
return (
180+
<div key={`divider-${idx}`} className="my-1 px-1">
181+
<div className="border-t-1 !border-gray-400 dark:!border-default-500" />
182+
</div>
183+
);
184+
}
185+
186+
return (
205187
<div
188+
key={`${item.title}-${item.subtitle ?? ""}-${item.description ?? ""}`}
206189
className={cn(
207-
"font-semibold flex items-center gap-2",
208-
heightCollapseRequired || minimalistMode ? "text-[0.65rem]" : "text-xs",
190+
"prompt-selection-item w-full flex flex-col rounded-lg",
191+
item.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
192+
idx === selectedIdx && !item.disabled && "bg-gray-100 dark:!bg-default-200",
193+
heightCollapseRequired || minimalistMode ? "px-2 py-1" : "p-2",
209194
)}
195+
role="button"
196+
tabIndex={item.disabled ? -1 : 0}
197+
onClick={() => {
198+
if (item.disabled) return;
199+
googleAnalytics.fireEvent(user?.id, `select_${normalizeName(item.title)}`, {});
200+
onSelect?.(item);
201+
}}
202+
onKeyDown={(e) => {
203+
if (e.key === "Enter" || e.key === " ") {
204+
if (item.disabled) return;
205+
googleAnalytics.fireEvent(user?.id, `select_${normalizeName(item.title)}`, {});
206+
onSelect?.(item);
207+
}
208+
}}
209+
onMouseEnter={() => {
210+
if (!isKeyboardNavigation && !item.disabled) {
211+
setSelectedIdx(idx);
212+
}
213+
}}
210214
>
211-
<span>{item.title}</span>
212-
{item.disabled && (
213-
<svg
214-
xmlns="http://www.w3.org/2000/svg"
215-
viewBox="0 0 24 24"
216-
fill="currentColor"
217-
className="w-3 h-3 text-gray-400"
218-
>
219-
<path
220-
fillRule="evenodd"
221-
d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z"
222-
clipRule="evenodd"
223-
/>
224-
</svg>
225-
)}
226-
{item.subtitle && (
227-
<span
228-
className={cn(
229-
"text-gray-500 font-normal",
230-
heightCollapseRequired || minimalistMode ? "text-[0.55rem]" : "text-[0.6rem]",
231-
)}
232-
>
233-
{item.subtitle}
234-
</span>
235-
)}
236-
</div>
237-
{(item.description || item.disabledReason) && (
238215
<div
239-
className="text-gray-500 text-nowrap whitespace-nowrap text-ellipsis overflow-hidden"
240-
style={{ fontSize: heightCollapseRequired || minimalistMode ? "0.5rem" : "0.65rem" }}
216+
className={cn(
217+
"font-semibold flex items-center gap-2",
218+
heightCollapseRequired || minimalistMode ? "text-[0.65rem]" : "text-xs",
219+
)}
241220
>
242-
{item.disabledReason || item.description}
221+
<span>{item.title}</span>
222+
{item.disabled && (
223+
<svg
224+
xmlns="http://www.w3.org/2000/svg"
225+
viewBox="0 0 24 24"
226+
fill="currentColor"
227+
className="w-3 h-3 text-gray-400"
228+
>
229+
<path
230+
fillRule="evenodd"
231+
d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z"
232+
clipRule="evenodd"
233+
/>
234+
</svg>
235+
)}
236+
{item.subtitle && (
237+
<span
238+
className={cn(
239+
"text-gray-500 font-normal",
240+
heightCollapseRequired || minimalistMode ? "text-[0.55rem]" : "text-[0.6rem]",
241+
)}
242+
>
243+
{(() => {
244+
const CUSTOM_SUFFIX = " (Custom)";
245+
if (item.subtitle.endsWith(CUSTOM_SUFFIX)) {
246+
const main = item.subtitle.slice(0, -CUSTOM_SUFFIX.length);
247+
return (
248+
<>
249+
<span>{main}</span>
250+
<span className="underline ml-1">{CUSTOM_SUFFIX.trim()}</span>
251+
</>
252+
);
253+
}
254+
return <>{item.subtitle}</>;
255+
})()}
256+
</span>
257+
)}
243258
</div>
244-
)}
245-
</div>
246-
))}
259+
{(item.description || item.disabledReason) && (
260+
<div
261+
className="text-gray-500 text-nowrap whitespace-nowrap text-ellipsis overflow-hidden"
262+
style={{ fontSize: heightCollapseRequired || minimalistMode ? "0.5rem" : "0.65rem" }}
263+
>
264+
{item.disabledReason || item.description}
265+
</div>
266+
)}
267+
</div>
268+
);
269+
})}
247270
</div>
248271
);
249272
}

0 commit comments

Comments
 (0)