Skip to content

Commit 0ad6ac4

Browse files
committed
Merge branch 'feat/2.6.0-beta4' into feat/2.6.0-beta4-cofco
2 parents 2a08a0f + 58cdbd0 commit 0ad6ac4

25 files changed

Lines changed: 318 additions & 51 deletions

File tree

src/backend/bisheng/api/v1/schemas.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,9 @@ class WSModel(BaseModel):
394394
id: str
395395
name: str | None = None
396396
displayName: str | None = None
397+
# Optional one-line intro shown under the model name in the workspace
398+
# model picker; hidden when empty. Length capped to keep the dropdown tidy.
399+
description: str | None = Field(default=None, max_length=50)
397400
visual: bool | None = False
398401

399402

src/backend/bisheng/llm/domain/services/llm.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ def _coerce_model_id(value: Any) -> int | None:
133133
return None
134134
return model_id if model_id > 0 else None
135135

136+
# Credential and endpoint fields in the server config. Users pasting values with
137+
# leading/trailing whitespace break the http request header(e.g. "Bearer sk-xxx ")
138+
STRIP_CONFIG_KEY_SUFFIXES = ('_key', '_secret', '_base', '_url', '_endpoint', '_proxy', '_version')
139+
140+
@classmethod
141+
def strip_config_whitespace(cls, config: Optional[dict]) -> Optional[dict]:
142+
""" Strip leading/trailing whitespace from credential and endpoint fields in the server config """
143+
if not isinstance(config, dict):
144+
return config
145+
result = {}
146+
for key, value in config.items():
147+
if isinstance(value, dict):
148+
result[key] = cls.strip_config_whitespace(value)
149+
elif isinstance(value, str) and key.endswith(cls.STRIP_CONFIG_KEY_SUFFIXES):
150+
result[key] = value.strip()
151+
else:
152+
result[key] = value
153+
return result
154+
136155
@classmethod
137156
async def _aget_inherited_system_default_server_ids_for_leaf(
138157
cls,
@@ -486,6 +505,7 @@ async def add_llm_server(
486505
raise ModelNameRepeatError.http_exception()
487506

488507
db_server = LLMServer(**server.model_dump(exclude={"models", "share_to_children"}))
508+
db_server.config = cls.strip_config_whitespace(db_server.config)
489509
db_server.user_id = login_user.user_id
490510

491511
db_server = await LLMDao.ainsert_server_with_models(
@@ -711,7 +731,8 @@ async def update_llm_server(
711731
exist_server.limit_flag = server.limit_flag
712732
exist_server.limit = server.limit
713733
mask_maker = JsonFieldMasker()
714-
exist_server.config = mask_maker.update_json_with_masked(exist_server.config, server.config)
734+
exist_server.config = cls.strip_config_whitespace(
735+
mask_maker.update_json_with_masked(exist_server.config, server.config))
715736

716737
# Route share_to_children flips through the dedicated DAO helper
717738
# so super-admin / Root-only invariants are enforced via FGA.

src/backend/bisheng/permission/api/endpoints/resource_permission.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,20 @@ async def authorize_resource(
14981498
and not _is_invalid_owner_subject(revoke.subject_type, revoke.relation)
14991499
]
15001500

1501+
# Users cannot modify their OWN permission in the member dialog: changing your
1502+
# own role (e.g. owner→editor) strips your management access and locks you out
1503+
# of the dialog on the next reload; removing yourself is likewise disallowed.
1504+
# Managing OTHERS is fine. Creator rows are already locked client-side via
1505+
# is_creator; this is the server-side backstop for every resource type.
1506+
self_subject_changes = [
1507+
item
1508+
for item in (tuple_grants + tuple_revokes)
1509+
if getattr(item, "subject_type", None) == "user"
1510+
and int(getattr(item, "subject_id", 0) or 0) == int(login_user.user_id)
1511+
]
1512+
if self_subject_changes:
1513+
return PermissionDeniedError.return_resp("不能修改自己的权限")
1514+
15011515
# Owner and creator are decoupled: an owner may be revoked/downgraded as long
15021516
# as another owner survives, but removing the last owner would orphan the
15031517
# resource (INV-2). Applies to ALL owner revokes (self or someone else's), and
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import unittest
2+
3+
from bisheng.llm.domain.services.llm import LLMService
4+
5+
6+
class TestStripConfigWhitespace(unittest.TestCase):
7+
def test_api_key_trailing_whitespace_stripped(self):
8+
config = {'openai_api_key': 'sk-xxx ', 'api_key': ' sk-yyy\n'}
9+
result = LLMService.strip_config_whitespace(config)
10+
self.assertEqual(result['openai_api_key'], 'sk-xxx')
11+
self.assertEqual(result['api_key'], 'sk-yyy')
12+
13+
def test_url_and_endpoint_fields_stripped(self):
14+
config = {
15+
'openai_api_base': ' https://api.openai.com/v1 ',
16+
'base_url': 'https://example.com/v1\t',
17+
'azure_endpoint': ' https://xx.openai.azure.com',
18+
'openai_proxy': ' http://127.0.0.1:7890 ',
19+
}
20+
result = LLMService.strip_config_whitespace(config)
21+
self.assertEqual(result['openai_api_base'], 'https://api.openai.com/v1')
22+
self.assertEqual(result['base_url'], 'https://example.com/v1')
23+
self.assertEqual(result['azure_endpoint'], 'https://xx.openai.azure.com')
24+
self.assertEqual(result['openai_proxy'], 'http://127.0.0.1:7890')
25+
26+
def test_nested_config_stripped(self):
27+
config = {'inner': {'api_key': 'sk-xxx '}}
28+
result = LLMService.strip_config_whitespace(config)
29+
self.assertEqual(result['inner']['api_key'], 'sk-xxx')
30+
31+
def test_other_fields_untouched(self):
32+
config = {'description': ' keep me ', 'streaming': True, 'max_tokens': 4096, 'voice': None}
33+
result = LLMService.strip_config_whitespace(config)
34+
self.assertEqual(result['description'], ' keep me ')
35+
self.assertEqual(result['streaming'], True)
36+
self.assertEqual(result['max_tokens'], 4096)
37+
self.assertIsNone(result['voice'])
38+
39+
def test_none_config(self):
40+
self.assertIsNone(LLMService.strip_config_whitespace(None))

src/backend/test/permission/test_permission_api_integration.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ def test_authorize_api_blocks_custom_model_with_permissions_outside_caller_set(s
319319
assert body["status_code"] == 19000
320320
mock_authorize.assert_not_awaited()
321321

322-
def test_authorize_api_blocks_self_owner_revoke_when_it_is_the_last_owner(self):
322+
def test_authorize_api_blocks_self_owner_revoke(self):
323+
"""Self-modification backstop: you cannot revoke your OWN owner (would lock
324+
you out of the dialog), regardless of how many owners remain."""
323325
app = _make_app(_ViewerUser)
324326

325327
with (
@@ -387,12 +389,15 @@ def test_authorize_api_blocks_self_owner_revoke_when_it_is_the_last_owner(self):
387389
)
388390
body = resp.json()
389391

390-
# Owner/creator decoupled: removing the last owner is now refused with the
391-
# dedicated PermissionLastOwnerError (19007) instead of a generic denial.
392-
assert body["status_code"] == 19007
392+
# Self owner revoke is blocked by the self-modification guard (19000),
393+
# which runs before the last-owner check.
394+
assert body["status_code"] == 19000
393395
mock_authorize.assert_not_awaited()
394396

395-
def test_authorize_api_allows_self_owner_revoke_when_another_owner_remains(self):
397+
def test_authorize_api_allows_owner_revoke_of_another_user_when_another_owner_remains(self):
398+
"""Revoking ANOTHER user's owner is allowed while an owner remains (caller
399+
is _ViewerUser=7; we revoke user 9, leaving 7). Self-revoke is covered by
400+
test_authorize_api_blocks_self_owner_revoke."""
396401
app = _make_app(_ViewerUser)
397402

398403
with (
@@ -462,7 +467,7 @@ def test_authorize_api_allows_self_owner_revoke_when_another_owner_remains(self)
462467
"revokes": [
463468
{
464469
"subject_type": "user",
465-
"subject_id": 7,
470+
"subject_id": 9,
466471
"relation": "owner",
467472
}
468473
],
@@ -816,6 +821,27 @@ def test_authorize_allows_removing_non_creator_owner_on_knowledge_space(self):
816821
assert body["status_code"] == 200
817822
mock_authorize.assert_awaited_once()
818823

824+
def test_authorize_blocks_modifying_own_permission(self):
825+
"""A user cannot change their OWN permission via the member dialog — a
826+
self-downgrade (owner→editor) would strip management access and lock them
827+
out. Backstop for every resource type; managing others is unaffected."""
828+
app = _make_app(_AdminUser) # user_id = 1
829+
with patch(
830+
"bisheng.permission.domain.services.permission_service.PermissionService.authorize",
831+
new_callable=AsyncMock,
832+
) as mock_authorize:
833+
with TestClient(app) as client:
834+
resp = client.post(
835+
"/api/v1/permissions/resources/workflow/wf-1/authorize",
836+
json={
837+
"grants": [{"subject_type": "user", "subject_id": 1, "relation": "editor"}],
838+
"revokes": [{"subject_type": "user", "subject_id": 1, "relation": "owner"}],
839+
},
840+
)
841+
body = resp.json()
842+
assert body["status_code"] == 19000
843+
mock_authorize.assert_not_awaited()
844+
819845
def test_permissions_list_requires_can_edit_on_resource(self):
820846
app = _make_app(_ViewerUser)
821847

src/frontend/client/src/components/Chat/AiModelSelect.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,15 @@ const AiModelSelect = memo(
7373
the model list is already in memory via `options`. */}
7474
<SelectContent auto className="bg-white w-auto min-w-[100px] max-w-[280px]">
7575
{uniqueOptions.map((opt) => (
76-
<SelectItem key={opt.id + ""} value={opt.id + ""}>
77-
{opt.displayName}
76+
<SelectItem key={opt.id + ""} value={opt.id + ""} textValue={opt.displayName}>
77+
<div className="flex min-w-0 flex-col py-0.5">
78+
<span>{opt.displayName}</span>
79+
{opt.description && (
80+
<span className="mt-0.5 whitespace-normal break-words text-xs text-gray-400">
81+
{opt.description}
82+
</span>
83+
)}
84+
</div>
7885
</SelectItem>
7986
))}
8087
</SelectContent>

src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111
import { ArrowRight, Check, ChevronLeft, ChevronRight, X } from 'lucide-react';
1212
import { Outlined } from 'bisheng-icons';
13-
import { useEffect, useMemo, useRef, useState } from 'react';
13+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1414
import { Textarea } from '~/components/ui';
1515
import { useLocalize } from '~/hooks';
1616
import { cn } from '~/utils';
@@ -168,6 +168,19 @@ function ClarifyCardInteractive({ data, disabled = false, onSubmit }: ClarifyCar
168168
return () => window.removeEventListener('keydown', onKey);
169169
}, []);
170170

171+
// The custom-answer <textarea> is one reused DOM node across pages, and its
172+
// grown height lives in an inline style set on input. On page change that
173+
// stale height would leak into the next question (or a saved multi-line answer
174+
// would show clipped). Re-fit it to the CURRENT question's content whenever the
175+
// page changes — empty content collapses back to one row.
176+
const customTaRef = useRef<HTMLTextAreaElement>(null);
177+
useLayoutEffect(() => {
178+
const el = customTaRef.current;
179+
if (!el) return;
180+
el.style.height = 'auto';
181+
el.style.height = `${el.scrollHeight}px`;
182+
}, [page]);
183+
171184
return (
172185
<div
173186
className="my-3 w-full rounded-2xl border border-[#EEF2F6] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.03)]"
@@ -269,52 +282,69 @@ function ClarifyCardInteractive({ data, disabled = false, onSubmit }: ClarifyCar
269282
<li>
270283
<div
271284
className={cn(
272-
'flex h-9 items-center gap-2 rounded-lg px-4 transition-all duration-200',
285+
// min-h (not fixed h) so the textarea can grow past one
286+
// line when the user inserts Shift+Enter newlines.
287+
// items-start: once grown, the number stays pinned to the
288+
// FIRST line (top) instead of centering on the tall box.
289+
// py-2 (8px) + a 20px line = 36px (h-9) when single-line.
290+
'flex min-h-9 items-start gap-2 rounded-lg px-4 py-2 transition-all duration-200',
273291
// No box by default (matches the other options); the
274292
// input-box background only appears once it's active.
275293
customSelected ? 'bg-[#EEE]' : 'hover:bg-gray-50/80',
276294
)}
277295
>
278-
<span className="shrink-0 text-sm font-medium text-[#8C8C8C]">
296+
<span className="shrink-0 text-sm font-medium leading-5 text-[#8C8C8C]">
279297
{q.options.length + 1}.
280298
</span>
281-
<input
282-
type="text"
299+
<textarea
300+
ref={customTaRef}
301+
rows={1}
283302
disabled={disabled || submitted}
284303
value={customText[q.id] || ''}
285-
placeholder={localize('com_linsight_clarify_custom')}
304+
// Default placeholder is the short "自行输入"; once the row is
305+
// highlighted (customSelected, i.e. focused/active) it grows the
306+
// Shift+Enter hint — matching when the bg-[#EEE] highlight shows.
307+
placeholder={localize(
308+
customSelected
309+
? 'com_linsight_clarify_custom_active'
310+
: 'com_linsight_clarify_custom',
311+
)}
286312
onFocus={() => !customSelected && handleSelect(q, CUSTOM_KEY)}
287313
onChange={(e) => {
288314
setCustomText((prev) => ({ ...prev, [q.id]: e.target.value }));
289315
if (!customSelected) handleSelect(q, CUSTOM_KEY);
316+
// Auto-grow to fit its content (Shift+Enter newlines).
317+
e.currentTarget.style.height = 'auto';
318+
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`;
290319
}}
291320
onKeyDown={(e) => {
292-
// Enter confirms the typed custom answer and advances to
293-
// the next question (both single- AND multi-select),
294-
// matching the "下一题 ↵" hint — the input is focused, so
295-
// the window-level Enter handler is bypassed and this is the
296-
// only place that can advance. Guard the IME composition
297-
// Enter so committing pinyin doesn't skip the question.
321+
// Enter (without Shift) confirms the typed custom answer and
322+
// advances to the next question (both single- AND
323+
// multi-select), matching the "下一题 ↵" hint — the field is
324+
// focused, so the window-level Enter handler is bypassed and
325+
// this is the only place that can advance. Shift+Enter falls
326+
// through to the textarea's native newline. Guard the IME
327+
// composition Enter so committing pinyin doesn't skip.
298328
if (
299329
e.key === 'Enter' &&
330+
!e.shiftKey &&
300331
!e.nativeEvent.isComposing &&
301332
customText[q.id]?.trim()
302333
) {
303334
e.preventDefault();
304335
handleConfirm();
305336
}
306337
}}
307-
className={cn(
308-
'flex-1 bg-transparent text-sm outline-none placeholder:text-[#8C8C8C]',
309-
customSelected ? 'text-[#1A1A1A] font-medium' : 'text-[#1A1A1A]',
310-
)}
338+
className="flex-1 resize-none border-0 bg-transparent p-0 text-sm font-normal leading-5 text-[#1A1A1A] outline-none placeholder:text-[#8C8C8C]"
311339
/>
312340
{!q.multiple && customSelected && customText[q.id]?.trim() && (
313341
<button
314342
type="button"
315343
disabled={disabled || submitted}
316344
onClick={handleConfirm}
317-
className="flex shrink-0 items-center gap-1 text-sm font-medium text-[#8C8C8C] hover:text-[#212121] disabled:opacity-50 transition-colors"
345+
// self-end pins 确定 to the bottom-right of the (possibly
346+
// grown) box, while the number stays top-left.
347+
className="flex shrink-0 self-end items-center gap-1 text-sm font-medium text-[#8C8C8C] hover:text-[#212121] disabled:opacity-50 transition-colors"
318348
>
319349
{localize('com_linsight_clarify_submit')}
320350
<Outlined.CornerDownLeft size={14} className="shrink-0" />

src/frontend/client/src/components/Linsight/Input/ModelSelector.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,17 @@ export function ModelSelector({ value, disabled = false, onChange }: ModelSelect
7070
{label}
7171
</span>
7272
</SelectTrigger>
73-
<SelectContent className="bg-white">
73+
<SelectContent className="bg-white max-w-[280px]">
7474
{options.map((opt: any) => (
75-
<SelectItem key={String(opt.id)} value={String(opt.id)}>
76-
{opt.displayName ?? opt.name}
75+
<SelectItem key={String(opt.id)} value={String(opt.id)} textValue={opt.displayName ?? opt.name}>
76+
<div className="flex min-w-0 flex-col py-0.5">
77+
<span>{opt.displayName ?? opt.name}</span>
78+
{opt.description && (
79+
<span className="mt-0.5 whitespace-normal break-words text-xs text-gray-400">
80+
{opt.description}
81+
</span>
82+
)}
83+
</div>
7784
</SelectItem>
7885
))}
7986
</SelectContent>

0 commit comments

Comments
 (0)