Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
91f42aa0
Commit
91f42aa0
authored
Apr 21, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(ui): refresh chat and settings interactions
parent
5e19ad5f
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
656 additions
and
92 deletions
+656
-92
App.tsx
apps/ui/src/App.tsx
+656
-92
No files found.
apps/ui/src/App.tsx
View file @
91f42aa0
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
"react"
;
import
brandIcon
from
"./assets/brand-icon.png"
;
import
type
{
ReactNode
,
ChangeEvent
,
KeyboardEvent
as
ReactKeyboardEvent
}
from
"react"
;
import
type
{
ReactNode
,
ChangeEvent
,
DragEvent
as
ReactDragEvent
,
KeyboardEvent
as
ReactKeyboardEvent
}
from
"react"
;
import
type
{
AppConfig
,
ChatAttachment
,
...
...
@@ -34,6 +34,7 @@ type Tone = "positive" | "warning" | "info";
type
MessageStreamState
=
"streaming"
|
"error"
;
type
SendPhase
=
"idle"
|
"preparing"
|
"streaming"
|
"finalizing"
;
type
TraceTone
=
"info"
|
"error"
|
"success"
;
type
MessageReaction
=
"up"
|
"down"
;
type
MessagesBySession
=
Record
<
string
,
UiChatMessage
[]
>
;
type
UiChatMessage
=
ChatMessage
&
{
...
...
@@ -564,7 +565,7 @@ const ui = {
expertReady
:
"
\
u5df2
\
u5c31
\
u7eea"
,
activeExpert
:
"
\
u5f53
\
u524d
\
u4e13
\
u5bb6"
,
starterQuestionsHint
:
"
\
u70b9
\
u51fb
\
u95ee
\
u9898
\
u540e
\
u4f1a
\
u5148
\
u586b
\
u5165
\
u8f93
\
u5165
\
u6846
\
uff0c
\
u4f60
\
u53ef
\
u4ee5
\
u7ee7
\
u7eed
\
u8865
\
u5145
\
u540e
\
u518d
\
u53d1
\
u9001
\
u3002"
,
taskPlaceholder
:
"
\
u8f93
\
u5165
\
u4f60
\
u7684
\
u9700
\
u6c42
\
uff0c
\
u53ef
\
u7528 Ctrl+Enter
\
u53d1
\
u9001
\
u3002"
,
taskPlaceholder
:
"
\
u8f93
\
u5165
\
u4f60
\
u7684
\
u9700
\
u6c42
\
uff0c
Enter
\
u53d1
\
u9001
\
uff0cShift+Enter
\
u6362
\
u884c
\
u3002"
,
taskDisabledPlaceholder
:
"
\
u8bf7
\
u5148
\
u7ed1
\
u5b9a
\
u5458
\
u5de5
\
u5bc6
\
u94a5
\
u540e
\
u5f00
\
u59cb
\
u5bf9
\
u8bdd
\
u3002"
,
send
:
"
\
u53d1
\
u9001"
,
sending
:
"
\
u53d1
\
u9001
\
u4e2d..."
,
...
...
@@ -615,6 +616,9 @@ const ui = {
suggestionDismiss
:
"暂不切换"
,
suggestionSwitchAction
:
"切换并继续"
,
saveSuccessPending
:
"
\
u5458
\
u5de5
\
u5bc6
\
u94a5
\
u5df2
\
u4fdd
\
u5b58
\
uff0c
\
u6b63
\
u5728
\
u540c
\
u6b65
\
u8fd0
\
u884c
\
u65f6
\
u914d
\
u7f6e
\
u3002"
,
saveSuccessApplied
:
"
\
u6a21
\
u578b
\
u914d
\
u7f6e
\
u5df2
\
u4fdd
\
u5b58
\
uff0c
\
u65b0
\
u7684
\
u914d
\
u7f6e
\
u5c06
\
u5728
\
u540e
\
u7eed
\
u6267
\
u884c
\
u4e2d
\
u751f
\
u6548
\
u3002"
,
copy
:
"
\
u590d
\
u5236"
,
copied
:
"
\
u5df2
\
u590d
\
u5236"
,
bindFirst
:
"
\
u8bf7
\
u5148
\
u7ed1
\
u5b9a"
,
bindFirstError
:
"
\
u8bf7
\
u5148
\
u7ed1
\
u5b9a
\
u5458
\
u5de5
\
u5bc6
\
u94a5
\
u540e
\
u518d
\
u53d1
\
u9001
\
u6d88
\
u606f
\
u3002"
,
startingHint
:
"
\
u6b63
\
u5728
\
u51c6
\
u5907
\
u8fd0
\
u884c
\
u73af
\
u5883
\
uff0c
\
u8bf7
\
u7a0d
\
u5019
\
u3002"
,
...
...
@@ -1291,15 +1295,338 @@ type ExpertVisualKey =
|
"geo"
|
"leads"
;
function
Send
Arrow
Icon
()
{
function
Send
Rocket
Icon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
aria
-
hidden=
"true"
>
<
path
d=
"M19 12H7"
/>
<
path
d=
"m12 7-5 5 5 5"
/>
<
span
className=
"send-rocket-pair"
aria
-
hidden=
"true"
>
{
[
0
,
1
].
map
((
index
)
=>
(
<
svg
key=
{
index
}
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M12.9 4.2c2.4 1.2 4.1 3.3 4.9 6.1-2.4.8-4.6 2.4-6.3 4.5l-1.9 2.5-1.9-.5.5-1.9 2.5-1.9c2.1-1.7 3.7-3.9 4.5-6.3-1.2-.3-2.4-.1-3.4.8l-4.2 4.2-2.6.7.7-2.6 4.2-4.2c1.6-1.6 3.9-2.1 6-1.4Z"
fill=
"currentColor"
/>
<
path
d=
"M8.2 15.8 6 18l-1.6-1.6 2.2-2.2"
stroke=
"currentColor"
strokeWidth=
"1.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
<
path
d=
"M14.3 5.6 18.4 9.7"
stroke=
"#ffffff"
strokeWidth=
"1.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
opacity=
"0.72"
/>
</
svg
>
))
}
</
span
>
);
}
function
AttachmentIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M8.5 12.7 14 7.2a3.5 3.5 0 1 1 5 5L11 20.1a5.5 5.5 0 0 1-7.8-7.8l8.2-8.2"
stroke=
"currentColor"
strokeWidth=
"1.8"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
</
svg
>
);
}
function
CopyIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
rect
x=
"9"
y=
"9"
width=
"10"
height=
"10"
rx=
"2"
stroke=
"currentColor"
strokeWidth=
"1.7"
/>
<
path
d=
"M6 15V7a2 2 0 0 1 2-2h8"
stroke=
"currentColor"
strokeWidth=
"1.7"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
</
svg
>
);
}
function
CheckIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M5.5 12.5 10 17l8.5-9"
stroke=
"currentColor"
strokeWidth=
"1.9"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
</
svg
>
);
}
function
ArrowUpIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M12 18V6"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
/>
<
path
d=
"m7 11 5-5 5 5"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
</
svg
>
);
}
function
RefreshIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M20 6v5h-5"
stroke=
"currentColor"
strokeWidth=
"1.7"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
<
path
d=
"M19 11a7 7 0 1 0 1.6 4.5"
stroke=
"currentColor"
strokeWidth=
"1.7"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
</
svg
>
);
}
function
ThumbIcon
({
direction
}:
{
direction
:
MessageReaction
})
{
return
direction
===
"up"
?
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M7 11v9H4v-9h3Zm3 9h6.4c1.2 0 2.2-.8 2.5-1.9l1.3-4.6c.4-1.5-.7-3-2.3-3H14V5.8c0-1-.8-1.8-1.8-1.8a1 1 0 0 0-.9.5L8.1 11.1a2 2 0 0 0-.3 1V20h2.2Z"
stroke=
"currentColor"
strokeWidth=
"1.6"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
</
svg
>
)
:
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M7 4v9H4V4h3Zm3 0h6.4c1.2 0 2.2.8 2.5 1.9l1.3 4.6c.4 1.5-.7 3-2.3 3H14v4.7c0 1-.8 1.8-1.8 1.8a1 1 0 0 1-.9-.5l-3.2-6.6a2 2 0 0 1-.3-1V4h2.2Z"
stroke=
"currentColor"
strokeWidth=
"1.6"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
</
svg
>
);
}
function
MoreIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"currentColor"
aria
-
hidden=
"true"
focusable=
"false"
>
<
circle
cx=
"6.5"
cy=
"12"
r=
"1.5"
/>
<
circle
cx=
"12"
cy=
"12"
r=
"1.5"
/>
<
circle
cx=
"17.5"
cy=
"12"
r=
"1.5"
/>
</
svg
>
);
}
function
formatCodeLanguageLabel
(
language
:
string
):
string
{
const
normalized
=
language
.
trim
().
toLowerCase
();
if
(
!
normalized
)
{
return
"Text"
;
}
const
aliases
:
Record
<
string
,
string
>
=
{
js
:
"JavaScript"
,
ts
:
"TypeScript"
,
jsx
:
"React JSX"
,
tsx
:
"React TSX"
,
py
:
"Python"
,
sh
:
"Shell"
,
yml
:
"YAML"
,
md
:
"Markdown"
};
return
aliases
[
normalized
]
??
normalized
.
charAt
(
0
).
toUpperCase
()
+
normalized
.
slice
(
1
);
}
function
renderMarkdownInline
(
text
:
string
,
keyPrefix
:
string
):
ReactNode
[]
{
const
nodes
:
ReactNode
[]
=
[];
const
pattern
=
/`
([^
`
]
+
)
`|
\[([^\]]
+
)\]\((
https
?
:
\/\/[^\s
)
]
+
)\)
|
\*\*([^
*
]
+
)\*\*
|__
([^
_
]
+
)
__|
\*([^
*
\n]
+
)\*
|_
([^
_
\n]
+
)
_/g
;
let
cursor
=
0
;
let
match
:
RegExpExecArray
|
null
;
let
tokenIndex
=
0
;
while
((
match
=
pattern
.
exec
(
text
))
!==
null
)
{
if
(
match
.
index
>
cursor
)
{
nodes
.
push
(
text
.
slice
(
cursor
,
match
.
index
));
}
const
key
=
`
${
keyPrefix
}
-
${
tokenIndex
}
`
;
if
(
match
[
1
])
{
nodes
.
push
(<
code
key=
{
key
}
className=
"markdown-inline-code"
>
{
match
[
1
]
}
</
code
>);
}
else
if
(
match
[
2
]
&&
match
[
3
])
{
nodes
.
push
(
<
a
key=
{
key
}
href=
{
match
[
3
]
}
target=
"_blank"
rel=
"noreferrer"
className=
"markdown-link"
>
{
renderMarkdownInline
(
match
[
2
],
`${key}-link`
)
}
</
a
>
);
}
else
if
(
match
[
4
]
||
match
[
5
])
{
nodes
.
push
(<
strong
key=
{
key
}
>
{
renderMarkdownInline
(
match
[
4
]
||
match
[
5
]
||
""
,
`${key}-strong`
)
}
</
strong
>);
}
else
if
(
match
[
6
]
||
match
[
7
])
{
nodes
.
push
(<
em
key=
{
key
}
>
{
renderMarkdownInline
(
match
[
6
]
||
match
[
7
]
||
""
,
`${key}-em`
)
}
</
em
>);
}
cursor
=
pattern
.
lastIndex
;
tokenIndex
+=
1
;
}
if
(
cursor
<
text
.
length
)
{
nodes
.
push
(
text
.
slice
(
cursor
));
}
return
nodes
;
}
function
renderParagraphLines
(
lines
:
string
[],
keyPrefix
:
string
):
ReactNode
[]
{
return
lines
.
flatMap
((
line
,
lineIndex
)
=>
[
lineIndex
>
0
?
<
br
key=
{
`${keyPrefix}-br-${lineIndex}`
}
/>
:
null
,
...
renderMarkdownInline
(
line
,
`
${
keyPrefix
}
-line-
${
lineIndex
}
`
)
]);
}
function
renderMarkdownHeading
(
level
:
number
,
key
:
string
,
children
:
ReactNode
[])
{
switch
(
level
)
{
case
1
:
return
<
h1
key=
{
key
}
>
{
children
}
</
h1
>;
case
2
:
return
<
h2
key=
{
key
}
>
{
children
}
</
h2
>;
case
3
:
return
<
h3
key=
{
key
}
>
{
children
}
</
h3
>;
case
4
:
return
<
h4
key=
{
key
}
>
{
children
}
</
h4
>;
case
5
:
return
<
h5
key=
{
key
}
>
{
children
}
</
h5
>;
default
:
return
<
h6
key=
{
key
}
>
{
children
}
</
h6
>;
}
}
function
renderMarkdownBlocks
(
text
:
string
,
keyPrefix
:
string
):
ReactNode
[]
{
const
normalized
=
text
.
replace
(
/
\r\n?
/g
,
"
\n
"
);
const
lines
=
normalized
.
split
(
"
\n
"
);
const
blocks
:
ReactNode
[]
=
[];
let
index
=
0
;
const
isBlockBoundary
=
(
line
:
string
)
=>
{
const
trimmed
=
line
.
trim
();
return
!
trimmed
||
/^#
{1,6}\s
+/
.
test
(
trimmed
)
||
/^>
\s?
/
.
test
(
trimmed
)
||
/^
[
-*_
]{3,}\s
*$/
.
test
(
trimmed
)
||
/^
\s
*
[
-*
]\s
+/
.
test
(
trimmed
)
||
/^
\s
*
\d
+
\.\s
+/
.
test
(
trimmed
);
};
while
(
index
<
lines
.
length
)
{
const
current
=
lines
[
index
];
const
trimmed
=
current
.
trim
();
if
(
!
trimmed
)
{
index
+=
1
;
continue
;
}
const
headingMatch
=
/^
(
#
{1,6})\s
+
(
.*
)
$/
.
exec
(
trimmed
);
if
(
headingMatch
)
{
const
level
=
Math
.
min
(
headingMatch
[
1
].
length
,
6
);
blocks
.
push
(
renderMarkdownHeading
(
level
,
`
${
keyPrefix
}
-heading-
${
index
}
`
,
renderMarkdownInline
(
headingMatch
[
2
],
`
${
keyPrefix
}
-heading-
${
index
}
`
)
)
);
index
+=
1
;
continue
;
}
if
(
/^
[
-*_
]{3,}\s
*$/
.
test
(
trimmed
))
{
blocks
.
push
(<
hr
key=
{
`${keyPrefix}-rule-${index}`
}
className=
"markdown-rule"
/>);
index
+=
1
;
continue
;
}
if
(
/^>
\s?
/
.
test
(
trimmed
))
{
const
quoteLines
:
string
[]
=
[];
while
(
index
<
lines
.
length
&&
/^>
\s?
/
.
test
(
lines
[
index
].
trim
()))
{
quoteLines
.
push
(
lines
[
index
].
replace
(
/^
\s
*>
\s?
/
,
""
));
index
+=
1
;
}
blocks
.
push
(
<
blockquote
key=
{
`${keyPrefix}-quote-${index}`
}
className=
"markdown-quote"
>
<
p
>
{
renderParagraphLines
(
quoteLines
,
`${keyPrefix}-quote-${index}`
)
}
</
p
>
</
blockquote
>
);
continue
;
}
if
(
/^
\s
*
[
-*
]\s
+/
.
test
(
trimmed
))
{
const
items
:
string
[]
=
[];
while
(
index
<
lines
.
length
&&
/^
\s
*
[
-*
]\s
+/
.
test
(
lines
[
index
].
trim
()))
{
items
.
push
(
lines
[
index
].
replace
(
/^
\s
*
[
-*
]\s
+/
,
""
));
index
+=
1
;
}
blocks
.
push
(
<
ul
key=
{
`${keyPrefix}-ul-${index}`
}
className=
"markdown-list markdown-list-unordered"
>
{
items
.
map
((
item
,
itemIndex
)
=>
(
<
li
key=
{
`${keyPrefix}-ul-${index}-${itemIndex}`
}
>
{
renderMarkdownInline
(
item
,
`${keyPrefix}-ul-item-${itemIndex}`
)
}
</
li
>
))
}
</
ul
>
);
continue
;
}
if
(
/^
\s
*
\d
+
\.\s
+/
.
test
(
trimmed
))
{
const
items
:
string
[]
=
[];
while
(
index
<
lines
.
length
&&
/^
\s
*
\d
+
\.\s
+/
.
test
(
lines
[
index
].
trim
()))
{
items
.
push
(
lines
[
index
].
replace
(
/^
\s
*
\d
+
\.\s
+/
,
""
));
index
+=
1
;
}
blocks
.
push
(
<
ol
key=
{
`${keyPrefix}-ol-${index}`
}
className=
"markdown-list markdown-list-ordered"
>
{
items
.
map
((
item
,
itemIndex
)
=>
(
<
li
key=
{
`${keyPrefix}-ol-${index}-${itemIndex}`
}
>
{
renderMarkdownInline
(
item
,
`${keyPrefix}-ol-item-${itemIndex}`
)
}
</
li
>
))
}
</
ol
>
);
continue
;
}
const
paragraphLines
:
string
[]
=
[];
while
(
index
<
lines
.
length
&&
!
isBlockBoundary
(
lines
[
index
]))
{
paragraphLines
.
push
(
lines
[
index
]);
index
+=
1
;
}
blocks
.
push
(
<
p
key=
{
`${keyPrefix}-p-${index}`
}
className=
"markdown-paragraph"
>
{
renderParagraphLines
(
paragraphLines
,
`${keyPrefix}-p-${index}`
)
}
</
p
>
);
}
return
blocks
;
}
function
renderMarkdownContent
(
content
:
string
,
options
:
{
messageId
:
string
;
copiedToken
:
string
;
onCopy
:
(
token
:
string
,
text
:
string
)
=>
void
|
Promise
<
void
>
;
}
):
ReactNode
[]
{
const
normalized
=
content
.
replace
(
/
\r\n?
/g
,
"
\n
"
);
const
blocks
:
ReactNode
[]
=
[];
const
fencePattern
=
/```
([^\n
`
]
*
)\n([\s\S]
*
?)
```/g
;
let
cursor
=
0
;
let
blockIndex
=
0
;
let
match
:
RegExpExecArray
|
null
;
while
((
match
=
fencePattern
.
exec
(
normalized
))
!==
null
)
{
const
proseBeforeFence
=
normalized
.
slice
(
cursor
,
match
.
index
);
if
(
proseBeforeFence
.
trim
())
{
blocks
.
push
(...
renderMarkdownBlocks
(
proseBeforeFence
,
`
${
options
.
messageId
}
-md-
${
blockIndex
}
`
));
blockIndex
+=
1
;
}
const
language
=
(
match
[
1
]
||
""
).
trim
().
split
(
/
\s
+/
)[
0
]
||
""
;
const
code
=
match
[
2
].
replace
(
/
\n
$/
,
""
);
const
copyToken
=
`message:
${
options
.
messageId
}
:code:
${
blockIndex
}
`
;
blocks
.
push
(
<
div
key=
{
`${options.messageId}-code-${blockIndex}`
}
className=
"markdown-code-block"
>
<
div
className=
"markdown-code-toolbar"
>
<
span
>
{
formatCodeLanguageLabel
(
language
)
}
</
span
>
<
button
type=
"button"
className=
{
"markdown-code-copy"
+
(
options
.
copiedToken
===
copyToken
?
" copied"
:
""
)
}
aria
-
label=
"复制代码"
title=
"复制代码"
onClick=
{
()
=>
void
options
.
onCopy
(
copyToken
,
code
)
}
>
{
options
.
copiedToken
===
copyToken
?
<
CheckIcon
/>
:
<
CopyIcon
/>
}
</
button
>
</
div
>
<
pre
className=
"markdown-code-pre"
>
<
code
>
{
code
}
</
code
>
</
pre
>
</
div
>
);
cursor
=
match
.
index
+
match
[
0
].
length
;
blockIndex
+=
1
;
}
const
trailingProse
=
normalized
.
slice
(
cursor
);
if
(
trailingProse
.
trim
())
{
blocks
.
push
(...
renderMarkdownBlocks
(
trailingProse
,
`
${
options
.
messageId
}
-md-
${
blockIndex
}
`
));
}
return
blocks
.
length
>
0
?
blocks
:
[
<
p
key=
{
`${options.messageId}-md-empty`
}
className=
"markdown-paragraph"
>
{
renderParagraphLines
([
normalized
],
`${options.messageId}-md-empty`
)
}
</
p
>
];
}
const
SIDEBAR_EXPERT_ENTRY_ORDER
=
new
Map
<
string
,
number
>
([
[
"xhs"
,
20
],
[
"douyin"
,
21
],
...
...
@@ -1670,16 +1997,21 @@ export default function App() {
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({});
const [messageReactions, setMessageReactions] = useState<Record<string, MessageReaction | undefined>>({});
const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({});
const [sessionActionMenuId, setSessionActionMenuId] = useState("");
const [skillMenuOpen, setSkillMenuOpen] = useState(false);
const [isComposerDragOver, setIsComposerDragOver] = useState(false);
const [copiedToken, setCopiedToken] = useState("");
const activeStreamRef = useRef<ActiveStreamState | null>(null);
const skillMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const copiedTokenResetRef = useRef<number | null>(null);
const composerDragDepthRef = useRef(0);
const startupWarmupRequestedRef = useRef(false);
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null);
const minimizeWindow = () => void desktopApi.window.minimize();
const maximizeWindow = () => void desktopApi.window.maximize();
const closeWindow = () => void desktopApi.window.close();
const catalogSkills = workspace?.skills ?? [];
const readySkills = useMemo(() => catalogSkills.filter((skill) => skill.ready), [catalogSkills]);
const effectiveSkills = useMemo(() => (readySkills.length ? [DEFAULT_SKILL, ...readySkills] : [DEFAULT_SKILL]), [readySkills]);
...
...
@@ -1771,6 +2103,16 @@ export default function App() {
const showStartupOverlay = startupStateActive && !hasVisibleConversation;
const sending = sendPhase !== "idle";
const canSend = isBound && hasConversationProject && (prompt.trim().length > 0 || Boolean(composerAttachment)) && !sending && !saving;
const hasPendingLobsterKey = lobsterKeyDraft.trim().length > 0;
const hasPendingModelKeys = Boolean(
imageModelApiKeyDraft.trim()
|| videoModelApiKeyDraft.trim()
|| copywritingModelApiKeyDraft.trim()
|| digitalHumanVolcAccessKeyDraft.trim()
|| digitalHumanVolcSecretKeyDraft.trim()
|| digitalHumanQiniuAccessKeyDraft.trim()
|| digitalHumanQiniuSecretKeyDraft.trim()
);
const sendButtonLabel = sendPhase === "preparing"
? ui.preparing
: sendPhase === "streaming" || sendPhase === "finalizing"
...
...
@@ -1785,7 +2127,7 @@ export default function App() {
const pluginGroups = useMemo(() => groupPluginsByStatus(workspace?.plugins), [workspace?.plugins]);
useEffect(() => {
if (viewMode !== "experts") {
if (viewMode !== "
chat" && viewMode !== "
experts") {
clearComposerAttachment();
}
}, [viewMode]);
...
...
@@ -1829,6 +2171,12 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [infoText]);
useEffect(() => () => {
if (copiedTokenResetRef.current !== null) {
window.clearTimeout(copiedTokenResetRef.current);
}
}, []);
function updateSessionMessages(sessionId: string, updater: (current: UiChatMessage[]) => UiChatMessage[]) {
if (!sessionId) {
return;
...
...
@@ -3060,7 +3408,7 @@ export default function App() {
setDigitalHumanVolcSecretKeyDraft("");
setDigitalHumanQiniuAccessKeyDraft("");
setDigitalHumanQiniuSecretKeyDraft("");
setInfoText(trimmedLobsterKey ? ui.saveSuccessPending :
"模型配置已保存。"
);
setInfoText(trimmedLobsterKey ? ui.saveSuccessPending :
ui.saveSuccessApplied
);
void refresh(false);
} catch (error) {
setErrorText(err(error));
...
...
@@ -3351,16 +3699,37 @@ export default function App() {
}
async function handleComposerKeyDown(event: ReactKeyboardEvent<HTMLTextAreaElement>) {
if (event.key !== "Enter" || event.shiftKey || event.altKey) {
if (event.
nativeEvent.isComposing || event.
key !== "Enter" || event.shiftKey || event.altKey) {
return;
}
if (!(event.ctrlKey || event.metaKey)) {
event.preventDefault();
await sendPrompt();
}
async function closeWindow() {
await desktopApi.window.close();
}
async function handleCopyText(token: string, text: string) {
const resolved = text.trim();
if (!resolved) {
return;
}
event.preventDefault();
await sendPrompt();
try {
await navigator.clipboard.writeText(resolved);
setCopiedToken(token);
if (copiedTokenResetRef.current !== null) {
window.clearTimeout(copiedTokenResetRef.current);
}
copiedTokenResetRef.current = window.setTimeout(() => {
setCopiedToken("");
copiedTokenResetRef.current = null;
}, 2000);
} catch (error) {
setErrorText(err(error));
}
}
async function continuePendingHomePromptInHome() {
...
...
@@ -3433,6 +3802,72 @@ export default function App() {
}
}
function acceptComposerAttachmentFile(file: File) {
const localPath = (file as File & { path?: string }).path?.trim();
if (!localPath) {
setErrorText("当前客户端未提供本地图片路径,无法把图片透传到项目工作区。");
return;
}
if (file.type && !file.type.startsWith("image/")) {
setErrorText("当前附件只支持图片。");
return;
}
setErrorText("");
setComposerAttachment({
kind: "image",
name: file.name || localPath.split(/[\\/]/).pop() || "image",
mimeType: file.type || "application/octet-stream",
localPath
});
}
function handleComposerDragEnter(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current += 1;
setIsComposerDragOver(true);
}
function handleComposerDragOver(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
function handleComposerDragLeave(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current = Math.max(0, composerDragDepthRef.current - 1);
if (composerDragDepthRef.current === 0) {
setIsComposerDragOver(false);
}
}
function handleComposerDrop(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current = 0;
setIsComposerDragOver(false);
const file = event.dataTransfer.files[0];
if (file) {
acceptComposerAttachmentFile(file);
}
}
async function openAttachmentPicker() {
if (window.qjcDesktop) {
const attachment = await desktopApi.chat.pickImageAttachment();
...
...
@@ -3453,6 +3888,11 @@ export default function App() {
return;
}
acceptComposerAttachmentFile(file);
event.target.value = "";
return;
/*
const localPath = (file as File & { path?: string }).path?.trim();
if (!localPath) {
setErrorText("当前客户端未提供本地图片路径,无法把图片透传到项目工作区。");
...
...
@@ -3485,9 +3925,35 @@ export default function App() {
mimeType: file.type || "application/octet-stream",
localPath
});
*/
event.target.value = "";
}
async function regenerateAssistantMessage(messageId: string) {
if (sending || !visibleSessionId || !sessionScopeProjectId) {
return;
}
const targetIndex = messages.findIndex((message) => message.id === messageId);
if (targetIndex < 0) {
return;
}
const previousUserMessage = [...messages.slice(0, targetIndex)].reverse().find((message) => message.role === "user" && message.content.trim());
if (!previousUserMessage) {
return;
}
await submitPrompt(previousUserMessage.content, selectedSkill.id, visibleSessionId, sessionScopeProjectId);
}
function toggleMessageReaction(messageId: string, reaction: MessageReaction) {
setMessageReactions((current) => ({
...current,
[messageId]: current[messageId] === reaction ? undefined : reaction
}));
}
function chooseSkill(skillId: string) {
setSelectedSkillId(skillId);
setSkillMenuOpen(false);
...
...
@@ -3506,6 +3972,7 @@ export default function App() {
function openSession(sessionId: string) {
setViewMode((current) => (current === "experts" ? "experts" : "chat"));
setActiveSessionId(sessionId);
setSessionActionMenuId("");
}
async function switchExpert(projectId: string) {
...
...
@@ -3587,14 +4054,15 @@ export default function App() {
const sidebarSessionLabel = viewMode === "experts" ? "专家会话" : "会话管理";
const selectedSkillBadge = selectedSkillId === DEFAULT_SKILL.id ? "千匠问天" : "@" + selectedSkill.name;
const
panel
NewSessionAction = (
const
sidebar
NewSessionAction = (
<button
type="button"
className="s
econdary conversation-new-session
"
className="s
idebar-new-session app-no-drag !flex !min-h-12 !items-center !justify-center !gap-2 !rounded-[18px] !border !border-[#d7e8ff] !bg-white !px-4 !text-[14px] !font-semibold !text-[#1d4ed8] !shadow-[0_14px_30px_rgba(59,130,246,0.08)] transition hover:!border-[#bfdbfe] hover:!bg-[#f8fbff]
"
disabled={projectActionPending || !isBound || !projects.length}
onClick={() => void createProjectSession()}
>
<span className="conversation-new-session-label">新建对话</span>
<span className="text-lg leading-none">+</span>
<span className="conversation-new-session-label">新对话</span>
</button>
);
const conversationPanelTitle = viewMode === "experts" ? activeExpertName : "对话";
...
...
@@ -3603,7 +4071,7 @@ export default function App() {
<span className="home-microcopy-icon">
<LobsterClawIcon />
</span>
<span className="home-microcopy-text">
{homeChatCopy.microcopy}
</span>
<span className="home-microcopy-text">
当前对话
</span>
<span className="home-microcopy-tag">{selectedSkillBadge}</span>
</div>
) : (
...
...
@@ -3636,11 +4104,7 @@ export default function App() {
</div>
);
const isDouyinExpertGuide = viewMode === "experts" && activeExpertKey === "douyin";
const composerPlaceholder = isBound
? viewMode === "experts"
? activeExpertGuide.placeholder ?? ui.taskPlaceholder
: ui.taskPlaceholder
: ui.taskDisabledPlaceholder;
const composerPlaceholder = isBound ? "" : ui.taskDisabledPlaceholder;
const activeEmptyState = viewMode === "experts" ? (
isDouyinExpertGuide ? (
<div className="empty-state expert-empty-state expert-empty-state-douyin">
...
...
@@ -3720,16 +4184,19 @@ export default function App() {
);
const messageListContent = (
<div className={"message-list" + (viewMode === "chat" ? " message-list-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " message-list-xiaohongshu" : "")}>
<div className={"message-list
chat-scroll-smooth !flex !min-h-0 !w-full !flex-1 !flex-col !gap-7 !overflow-y-auto !bg-transparent !px-0 !py-3
" + (viewMode === "chat" ? " message-list-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " message-list-xiaohongshu" : "")}>
{messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null;
const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined;
const hasTrace = Boolean(messageTrace?.items.length);
const isTraceExpanded = Boolean(messageTrace?.expanded);
const canCopyMessage = Boolean(message.content.trim());
const copyToken = `
message
:
$
{
message
.
id
}
`;
const reaction = messageReactions[message.id];
return (
<article key={message.id} className={"message-card
" + message.role + (message.streamState ? " " + message.streamState : "
")}>
<div className=
"message-bubble"
>
<article key={message.id} className={"message-card
group relative !w-full !max-w-full !min-w-0 " + message.role + (message.streamState ? " " + message.streamState : "") + (message.role === "user" ? " !flex !justify-end" : " !flex !justify-start
")}>
<div className=
{"message-bubble " + (message.role === "assistant" ? "!w-full !max-w-full !min-w-0 !rounded-none !border-0 !bg-transparent !pl-[2ch] !pr-0 !py-0 !shadow-none" : "animate-user-bubble-in !ml-auto !inline-flex !w-fit !max-w-[min(82%,720px)] !min-w-0 !flex-col !rounded-[20px] !border !border-[#dbeafe] !bg-[#f0f7ff] !px-5 !py-4 !shadow-[0_12px_30px_rgba(59,130,246,0.08)]")}
>
{showThinking ? (
videoStatusCard ? (
<div className="generation-status-card" aria-live="polite">
...
...
@@ -3752,10 +4219,21 @@ export default function App() {
</div>
)
) : message.content ? (
<p>
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
message.role === "assistant" ? (
<div className="markdown-body !gap-4 text-[15px] leading-8 text-[#0f172a]">
{renderMarkdownContent(message.content, {
messageId: message.id,
copiedToken,
onCopy: handleCopyText
})}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</div>
) : (
<p className="message-plain-text !m-0 text-[15px] leading-8 text-[#0f172a]">
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
)
) : null}
{hasTrace ? (
<div className="message-trace">
...
...
@@ -3776,6 +4254,51 @@ export default function App() {
</div>
) : null}
</div>
{message.role === "assistant" && canCopyMessage ? (
<div className="message-card-actions !mt-3 !justify-start !opacity-0 !transition !duration-150 group-hover:!opacity-100">
<button
type="button"
className={"message-action-icon !h-8 !w-8 !rounded-full !border-0 !bg-white !text-[#64748b] !shadow-[0_10px_24px_rgba(148,163,184,0.18)] hover:!bg-[#f8fbff] " + (copiedToken === copyToken ? " copied !bg-[#ecfdf3] !text-[#16a34a]" : "")}
onClick={() => void handleCopyText(copyToken, message.content)}
aria-label="复制消息"
title="复制消息"
>
{copiedToken === copyToken ? <CheckIcon /> : <CopyIcon />}
</button>
{message.role === "assistant" ? (
<>
<button
type="button"
className="hidden"
onClick={() => void regenerateAssistantMessage(message.id)}
disabled={sending}
aria-label="重新生成"
title="重新生成"
>
<RefreshIcon />
</button>
<button
type="button"
className="hidden"
onClick={() => toggleMessageReaction(message.id, "up")}
aria-label="赞"
title="赞"
>
<ThumbIcon direction="up" />
</button>
<button
type="button"
className="hidden"
onClick={() => toggleMessageReaction(message.id, "down")}
aria-label="踩"
title="踩"
>
<ThumbIcon direction="down" />
</button>
</>
) : null}
</div>
) : null}
</article>
);
})}
...
...
@@ -3828,7 +4351,17 @@ export default function App() {
: messageListContent;
const composerContent = (
<div className={"composer-shell" + (viewMode === "chat" ? " composer-shell-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " composer-shell-xiaohongshu" : "")}>
<form
className={"composer-shell relative !mt-2 !flex !w-full !flex-col !gap-3 !rounded-[24px] !border !border-[#d7e8ff] !bg-white !px-5 !py-4 !shadow-[0_24px_60px_rgba(148,163,184,0.14)]" + (isComposerDragOver ? " dragging !border-[#60a5fa] !bg-[#f8fbff]" : "") + (viewMode === "chat" ? " composer-shell-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " composer-shell-xiaohongshu" : "")}
onSubmit={(event) => {
event.preventDefault();
void sendPrompt();
}}
onDragEnter={handleComposerDragEnter}
onDragOver={handleComposerDragOver}
onDragLeave={handleComposerDragLeave}
onDrop={handleComposerDrop}
>
<input
ref={attachmentInputRef}
className="composer-attachment-input"
...
...
@@ -3837,18 +4370,20 @@ export default function App() {
tabIndex={-1}
onChange={handleAttachmentSelection}
/>
<label className="composer-field">
{isComposerDragOver ? <div className="composer-drop-indicator !min-h-14 !rounded-[18px] !border-dashed !border-[#93c5fd] !bg-[#f0f7ff] !text-[#2563eb]">释放以上传图片</div> : null}
<label className="composer-field !gap-0">
<textarea
value={prompt}
disabled={!isBound}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => void handleComposerKeyDown(event)}
placeholder={composerPlaceholder}
className="!min-h-[60px] !rounded-none !border-0 !bg-transparent !p-0 !text-[15px] !leading-8 !text-[#0f172a] placeholder:!text-transparent"
/>
</label>
{composerAttachment ? (
<div className="composer-attachment-strip">
<span className="composer-attachment-chip">
<div className="composer-attachment-strip
!mt-0
">
<span className="composer-attachment-chip
!rounded-full !border !border-[#d7e8ff] !bg-[#f0f7ff] !px-3 !py-2
">
<span className="composer-attachment-chip-label">{composerAttachment.name}</span>
<button type="button" className="composer-attachment-remove" onClick={() => clearComposerAttachment()} aria-label="移除图片附件">
x
...
...
@@ -3856,14 +4391,17 @@ export default function App() {
</span>
</div>
) : null}
<div className="composer-footer">
<div className="composer-left-tools" ref={skillMenuRef}>
<div className="composer-footer !items-end !justify-between !gap-4">
<div className="composer-left-tools !flex-1 !items-center !gap-2" ref={skillMenuRef}>
<button type="button" className="attachment-trigger icon-only !h-11 !w-11 !rounded-full !border !border-[#d7e8ff] !bg-[#f0f7ff] !text-[#2563eb] hover:!bg-[#e0f2fe]" disabled={!isBound || sending} onClick={openAttachmentPicker} aria-label="上传图片" title="上传图片">
<AttachmentIcon />
</button>
{viewMode === "experts" ? (
<button type="button" className="attachment-trigger" disabled={!isBound || sending} onClick={openAttachmentPicker}>
图片
</button>
) : null}
<button type="button" className="skill-trigger" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
<button type="button" className="skill-trigger
!h-11 !rounded-full !border !border-[#e2e8f0] !bg-white !px-4 !text-[#334155]
" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
@
</button>
{selectedSkillId !== DEFAULT_SKILL.id ? (
...
...
@@ -3892,16 +4430,23 @@ export default function App() {
</div>
) : null}
</div>
<button className="composer-submit" disabled={!canSend} onClick={() => void sendPrompt()} aria-label={sendButtonLabel} title={sendButtonLabel}>
<SendArrowIcon />
<button
type="submit"
className={"composer-submit !h-12 !w-12 !rounded-full !border-0 !bg-[#2563eb] !text-white !shadow-[0_16px_30px_rgba(37,99,235,0.28)] hover:!bg-[#1d4ed8] " + (sending ? " is-busy" : "")}
disabled={!canSend}
aria-label={sendButtonLabel}
title={sendButtonLabel}
>
{sending ? <span className="composer-submit-spinner" aria-hidden="true" /> : <ArrowUpIcon />}
<span className="visually-hidden">{sendButtonLabel}</span>
</button>
</div>
</div>
<p className="composer-hint !m-0 !text-[11px] !text-[#94a3b8]">按 Enter 发送,Shift + Enter 换行</p>
</form>
);
return (
<div className="shell openclaw-theme">
<div className="shell openclaw-theme
!grid !grid-cols-[280px_minmax(0,1fr)] !bg-[#f0f7ff]
">
<div className="window-controls" aria-label="窗口控制">
<button type="button" className="window-control-button" aria-label="最小化窗口" onClick={minimizeWindow}>
<WindowControlIcon kind="minimize" />
...
...
@@ -3913,9 +4458,9 @@ export default function App() {
<WindowControlIcon kind="close" />
</button>
</div>
<aside className="sidebar">
<div className="sidebar-top">
<div className="sidebar-logo-block" aria-label="千匠问天">
<aside className="sidebar
app-drag-region !w-[280px] !border-r !border-[#dbeafe] !bg-[#f9fbff] !px-4 !py-5
">
<div className="sidebar-top
!gap-4
">
<div className="sidebar-logo-block
!rounded-[20px] !border !border-[#dbeafe] !bg-white !px-4 !py-4 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]
" aria-label="千匠问天">
<div className="sidebar-logo-mark-shell" aria-hidden="true">
<img src={brandIcon} alt="" className="sidebar-logo-mark" />
</div>
...
...
@@ -3923,14 +4468,14 @@ export default function App() {
<strong>千匠问天</strong>
</div>
</div>
<nav className="nav-list">
<nav className="nav-list
!gap-2
">
{[
{ id: "chat" as const, label: "对话" },
{ id: "experts" as const, label: ui.experts },
{ id: "plugins" as const, label: ui.plugins },
{ id: "settings" as const, label: ui.settings }
].map((item) => (
<button key={item.id} type="button" className={"nav-item
" + (viewMode === item.id ? " active" : "
")} onClick={() => void handleNavSelection(item.id)}>
<button key={item.id} type="button" className={"nav-item
app-no-drag !min-h-12 !rounded-[18px] !px-4 !text-[14px] !font-medium !shadow-none transition " + (viewMode === item.id ? " active !bg-[#f0f7ff] !text-[#1d4ed8] !shadow-[inset_0_0_0_1px_rgba(191,219,254,0.95)]" : "!bg-transparent !text-[#334155] hover:!bg-white hover:!text-[#0f172a]
")} onClick={() => void handleNavSelection(item.id)}>
<span className="nav-item-icon" aria-hidden="true">
<NavIcon kind={item.id} />
</span>
...
...
@@ -3938,12 +4483,13 @@ export default function App() {
</button>
))}
</nav>
{!showBindEntry ? sidebarNewSessionAction : null}
</div>
<div className="sidebar-bottom">
<section className="sidebar-section compact sidebar-experts-entry">
<div className="sidebar-bottom
!gap-4
">
<section className="sidebar-section compact sidebar-experts-entry
!rounded-[20px] !border !border-[#dbeafe] !bg-white !p-3 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]
">
{sidebarExpertEntries.length ? (
<div className="sidebar-expert-scroll">
<div className="expert-chip-list preview">
<div className="expert-chip-list preview
!gap-2
">
{sidebarExpertEntries.map((entry) => {
const expertVisualKey = resolveExpertVisualKey(entry.project, entry.definition);
const isStandalone = entry.definition.entryMode === "standalone";
...
...
@@ -3954,7 +4500,7 @@ export default function App() {
<button
key={entry.definition.id}
type="button"
className={"expert-chip expert-chip-" + expertVisualKey +
(isActive ? " active" : "
")}
className={"expert-chip expert-chip-" + expertVisualKey +
" app-no-drag !min-h-14 !rounded-[18px] !border !px-3 !py-3 !shadow-none transition " + (isActive ? " active !border-[#bfdbfe] !bg-[#f0f7ff]" : "!border-transparent !bg-[#f8fbff] hover:!border-[#dbeafe] hover:!bg-white
")}
disabled={projectActionPending || !entry.isAvailable}
onClick={() => {
if (isStandalone) {
...
...
@@ -3980,28 +4526,42 @@ export default function App() {
) : null}
</section>
{!showBindEntry ? (
<section className="sidebar-section sidebar-section-fill compact sidebar-session-section">
<section className="sidebar-section sidebar-section-fill compact sidebar-session-section
!rounded-[20px] !border !border-[#dbeafe] !bg-white !p-3 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]
">
<div className="sidebar-section-head sidebar-section-head-subtle">
<div className="sidebar-section-copy">
<span className="sidebar-section-label">{sidebarSessionLabel}</span>
</div>
</div>
<div className="sidebar-session-list">
<div className="sidebar-session-list
!gap-2
">
{sessions.map((session, index) => (
<div key={session.id} className={"sidebar-session-card
" + (activeSessionId === session.id ? " active" : "
")}>
<button type="button" className="sidebar-session-main" disabled={projectActionPending} onClick={() => openSession(session.id)}>
<div key={session.id} className={"sidebar-session-card
!rounded-[16px]" + (activeSessionId === session.id ? " active !bg-[#f0f7ff]" : " hover:!bg-[#f8fbff]
")}>
<button type="button" className="sidebar-session-main
app-no-drag !min-h-12 !rounded-[16px] !px-3 !text-left !shadow-none
" disabled={projectActionPending} onClick={() => openSession(session.id)}>
<strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button>
{sessions.length > 1 ? (
<button
type="button"
className="sidebar-session-close"
aria-label={ui.closeSession}
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => void closeProjectSession(session.id)}
>
x
</button>
<div className="sidebar-session-actions">
<button
type="button"
className={"sidebar-session-close" + (sessionActionMenuId === session.id ? " active" : "")}
aria-label="会话操作"
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => setSessionActionMenuId((current) => current === session.id ? "" : session.id)}
>
<MoreIcon />
</button>
{sessionActionMenuId === session.id ? (
<div className="sidebar-session-menu">
<button
type="button"
className="sidebar-session-menu-item"
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => void closeProjectSession(session.id)}
>
{ui.closeSession}
</button>
</div>
) : null}
</div>
) : null}
</div>
))}
...
...
@@ -4010,13 +4570,14 @@ export default function App() {
) : null}
</div>
</aside>
<div className="main-shell">
<div className="main-shell
!bg-[#f0f7ff]
">
{!isConversationView ? (
<div className="page-topbar">
<div className="page-copy">
<h2>{pageTitle}</h2>
<p>{pageDesc}</p>
</div>
<div className="page-drag-strip" aria-hidden="true" />
<div className="header-actions">
<StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip>
{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}
...
...
@@ -4027,18 +4588,18 @@ export default function App() {
{errorText ? <div className="notice error">{errorText}</div> : null}
<main className="content-area">
{isConversationView ? (
<section className={"panel chat-panel conversation-panel" + (viewMode === "chat" ? " conversation-panel-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-xiaohongshu" : "")}>
<div className="conversation-panel-head">
<section className={"panel chat-panel conversation-panel
!gap-5 !rounded-none !bg-transparent !px-7 !py-6
" + (viewMode === "chat" ? " conversation-panel-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-xiaohongshu" : "")}>
<div className="conversation-panel-head
conversation-panel-head-layout app-drag-region !grid !items-center !gap-4 !border-0 !pb-0
">
<div className="conversation-panel-copy">
{conversationPanelLead}
</div>
<div className="conversation-panel-actions">
<div className="conversation-drag-strip" aria-hidden="true" />
<div className="conversation-panel-actions app-no-drag !flex !items-center !gap-2">
<StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip>
{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}
{!showBindEntry ? panelNewSessionAction : null}
</div>
</div>
<div className={"conversation-panel-body" + (viewMode === "chat" ? " conversation-panel-body-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-body-xiaohongshu" : "")}>
<div className={"conversation-panel-body
!flex !min-h-0 !flex-1 !flex-col !overflow-hidden !rounded-[28px] !border !border-[#dbeafe] !bg-white/78 !px-0 !py-6 !shadow-[0_24px_60px_rgba(148,163,184,0.12)] backdrop-blur
" + (viewMode === "chat" ? " conversation-panel-body-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-body-xiaohongshu" : "")}>
{conversationStatusNotice}
{viewMode === "chat" ? homeIntentSuggestionNotice : null}
{conversationBodyContent}
...
...
@@ -4076,6 +4637,7 @@ export default function App() {
) : null}
{viewMode === "settings" ? (
<div className="page-stack settings-page-stack">
{showSettingsStatusHint ? <div className={"inline-hint settings-runtime-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
<section className="panel settings-panel settings-panel-hero compact settings-panel-modern">
<div className="settings-section-card">
<div className="settings-section-headline">
...
...
@@ -4085,7 +4647,8 @@ export default function App() {
</div>
</div>
<div className="settings-field-grid single">
<label>
<label className="settings-input-label">
<span className="settings-input-label-text">员工密钥</span>
<input
type="password"
value={lobsterKeyDraft}
...
...
@@ -4095,7 +4658,7 @@ export default function App() {
</label>
</div>
<div className="button-row settings-actions">
<button disabled={saving ||
lobsterKeyDraft.trim().length === 0
} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button>
<button disabled={saving ||
!hasPendingLobsterKey
} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button>
</div>
</div>
<div className="settings-section-card">
...
...
@@ -4116,8 +4679,8 @@ export default function App() {
<StatusChip tone={config?.expertModelConfig.copywriting.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.copywriting.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="settings-field-grid single">
<label>
api_key
<label
className="settings-input-label"
>
<span className="settings-input-label-text">API Key</span>
<input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} />
</label>
</div>
...
...
@@ -4131,8 +4694,8 @@ export default function App() {
<StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="settings-field-grid single">
<label>
api_key
<label
className="settings-input-label"
>
<span className="settings-input-label-text">API Key</span>
<input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} />
</label>
</div>
...
...
@@ -4146,8 +4709,8 @@ export default function App() {
<StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="settings-field-grid single">
<label>
api_key
<label
className="settings-input-label"
>
<span className="settings-input-label-text">API Key</span>
<input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} />
</label>
</div>
...
...
@@ -4175,27 +4738,27 @@ export default function App() {
</StatusChip>
</div>
<div className="settings-field-grid">
<label>
VOLC_ACCESS_KEY
<label
className="settings-input-label"
>
<span className="settings-input-label-text">VOLC_ACCESS_KEY</span>
<input type="password" value={digitalHumanVolcAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"} onChange={(event) => setDigitalHumanVolcAccessKeyDraft(event.target.value)} />
</label>
<label>
VOLC_SECRET_KEY
<label
className="settings-input-label"
>
<span className="settings-input-label-text">VOLC_SECRET_KEY</span>
<input type="password" value={digitalHumanVolcSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"} onChange={(event) => setDigitalHumanVolcSecretKeyDraft(event.target.value)} />
</label>
<label>
QINIU_ACCESS_KEY
<label
className="settings-input-label"
>
<span className="settings-input-label-text">QINIU_ACCESS_KEY</span>
<input type="password" value={digitalHumanQiniuAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"} onChange={(event) => setDigitalHumanQiniuAccessKeyDraft(event.target.value)} />
</label>
<label>
QINIU_SECRET_KEY
<label
className="settings-input-label"
>
<span className="settings-input-label-text">QINIU_SECRET_KEY</span>
<input type="password" value={digitalHumanQiniuSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"} onChange={(event) => setDigitalHumanQiniuSecretKeyDraft(event.target.value)} />
</label>
</div>
</article>
</div>
<div className="button-row settings-actions">
<button disabled={saving} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button>
<button disabled={saving
|| !hasPendingModelKeys
} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button>
</div>
</div>
</section>
...
...
@@ -4319,14 +4882,13 @@ export default function App() {
<p>{ui.diagnosticsDesc}</p>
</div>
</div>
<div className="settings-
field-grid single
">
<
label
>
{ui.workspacePath}
<
input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} /
>
</
label
>
<div className="settings-
static-list
">
<
div className="settings-static-item"
>
<span>{ui.workspacePath}</span>
<
strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong
>
</
div
>
</div>
<div className="diagnostic-meta-list">
<div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div>
{runtimeCloudStatus ? (
<>
<div className="mini-info"><span>Runtime Cloud Target</span><strong>{runtimeCloudStatus.baseUrl || ui.none}</strong></div>
...
...
@@ -4336,7 +4898,9 @@ export default function App() {
) : null}
</div>
<div className="button-row settings-actions">
<button disabled={saving} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存工作区设置"}</button>
<button className="secondary" onClick={() => void handleCopyText("workspace-path", config?.workspacePath || workspacePathDraft || ui.none)}>
{copiedToken === "workspace-path" ? ui.copied : ui.copy}
</button>
<button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button>
</div>
</div>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment