@vibe-forge/client 0.3.0 → 0.4.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.
Files changed (158) hide show
  1. package/cli.cjs +1 -1
  2. package/dist/assets/{arc-CwMXUVsq.js → arc-DgIxeTMg.js} +1 -1
  3. package/dist/assets/{blockDiagram-c4efeb88-CGxJV7KJ.js → blockDiagram-c4efeb88-CEAob3X9.js} +1 -1
  4. package/dist/assets/{c4Diagram-c83219d4-BKhin7cY.js → c4Diagram-c83219d4-DwIxpDKd.js} +1 -1
  5. package/dist/assets/channel-DhtnrNJ6.js +1 -0
  6. package/dist/assets/{classDiagram-beda092f-BASmn22R.js → classDiagram-beda092f-Cz1q8u_0.js} +1 -1
  7. package/dist/assets/{classDiagram-v2-2358418a-BUk9rNBX.js → classDiagram-v2-2358418a-CImgTuwd.js} +1 -1
  8. package/dist/assets/clone-7bHB6YkC.js +1 -0
  9. package/dist/assets/{createText-1719965b-2XqnWjQY.js → createText-1719965b-C1_HJcCc.js} +1 -1
  10. package/dist/assets/devicon-BWlTeAUU.woff +0 -0
  11. package/dist/assets/devicon-CirD-cQx.ttf +0 -0
  12. package/dist/assets/devicon-Dg8iWy0i.svg +1211 -0
  13. package/dist/assets/devicon-TqfHp33-.eot +0 -0
  14. package/dist/assets/{edges-96097737-B7e32Jeg.js → edges-96097737-BU8qStzd.js} +1 -1
  15. package/dist/assets/{erDiagram-0228fc6a-CCR2or72.js → erDiagram-0228fc6a-DNA1Fz2L.js} +1 -1
  16. package/dist/assets/{flowDb-c6c81e3f-B72HWT9x.js → flowDb-c6c81e3f-DjiCStMN.js} +1 -1
  17. package/dist/assets/{flowDiagram-50d868cf-WOi0KARY.js → flowDiagram-50d868cf-CSDi0-RD.js} +1 -1
  18. package/dist/assets/flowDiagram-v2-4f6560a1-_13Sz5Wh.js +1 -0
  19. package/dist/assets/{flowchart-elk-definition-6af322e1-i_Yd0LCE.js → flowchart-elk-definition-6af322e1-DrhIMas7.js} +1 -1
  20. package/dist/assets/{ganttDiagram-a2739b55-CFH9zF14.js → ganttDiagram-a2739b55-CTZnUP5z.js} +1 -1
  21. package/dist/assets/{gitGraphDiagram-82fe8481-DglKfMze.js → gitGraphDiagram-82fe8481-COOW7jTi.js} +1 -1
  22. package/dist/assets/{graph-BKbBNGPf.js → graph-CIkpD4Kx.js} +1 -1
  23. package/dist/assets/{index-5325376f-BK7F9nSl.js → index-5325376f-aVVRRTIu.js} +1 -1
  24. package/dist/assets/index-D1giUI7r.css +1 -0
  25. package/dist/assets/index-DRSI_ZIL.js +514 -0
  26. package/dist/assets/{infoDiagram-8eee0895-BLFL77_D.js → infoDiagram-8eee0895-DQpZ1LVD.js} +1 -1
  27. package/dist/assets/{journeyDiagram-c64418c1-CS9XctDL.js → journeyDiagram-c64418c1-DoKguIuk.js} +1 -1
  28. package/dist/assets/{layout-By3JZZGt.js → layout-Tnmha8Nh.js} +1 -1
  29. package/dist/assets/{line-9GUsXbwv.js → line-BQR2SOyl.js} +1 -1
  30. package/dist/assets/{linear-DzGV4E9N.js → linear-DlG0eemV.js} +1 -1
  31. package/dist/assets/{mermaid.core-CG3Ib42Q.js → mermaid.core-BnwYO0He.js} +6 -6
  32. package/dist/assets/{mindmap-definition-8da855dc-WQ3LPKJU.js → mindmap-definition-8da855dc-BllYwDID.js} +1 -1
  33. package/dist/assets/{pieDiagram-a8764435-DHVIUZiN.js → pieDiagram-a8764435-DwCkhPVc.js} +1 -1
  34. package/dist/assets/{quadrantDiagram-1e28029f-C3G9Ye8-.js → quadrantDiagram-1e28029f-c40GKTU0.js} +1 -1
  35. package/dist/assets/{requirementDiagram-08caed73-C9ES1D5G.js → requirementDiagram-08caed73-DnQp2Tk6.js} +1 -1
  36. package/dist/assets/{sankeyDiagram-a04cb91d-B4BKXclQ.js → sankeyDiagram-a04cb91d-CnJrs13b.js} +1 -1
  37. package/dist/assets/{sequenceDiagram-c5b8d532-DrgEb25G.js → sequenceDiagram-c5b8d532-1YBwnpKu.js} +1 -1
  38. package/dist/assets/{stateDiagram-1ecb1508-CF1XWARJ.js → stateDiagram-1ecb1508-BFBxQ6Fh.js} +1 -1
  39. package/dist/assets/{stateDiagram-v2-c2b004d7-IO3i3yXv.js → stateDiagram-v2-c2b004d7-Dmechvv2.js} +1 -1
  40. package/dist/assets/{styles-b4e223ce-DACN9aSc.js → styles-b4e223ce-DWWfWX8O.js} +1 -1
  41. package/dist/assets/{styles-ca3715f6-bekm2WLP.js → styles-ca3715f6-CKKvZxaU.js} +1 -1
  42. package/dist/assets/{styles-d45a18b0-OzTDVBb8.js → styles-d45a18b0-dKMOUh9p.js} +1 -1
  43. package/dist/assets/{svgDrawCommon-b86b1483-BWroJerr.js → svgDrawCommon-b86b1483-CBgjChPM.js} +1 -1
  44. package/dist/assets/{timeline-definition-faaaa080-CCfRNigO.js → timeline-definition-faaaa080-NCt-HHmb.js} +1 -1
  45. package/dist/assets/{xychartDiagram-f5964ef8-C3cbfVqN.js → xychartDiagram-f5964ef8-BJhXS4dG.js} +1 -1
  46. package/dist/index.html +2 -7
  47. package/index.html +0 -5
  48. package/package.json +11 -6
  49. package/src/App.tsx +2 -0
  50. package/src/api/README.md +26 -0
  51. package/src/api/automation.ts +88 -0
  52. package/src/api/base.ts +54 -0
  53. package/src/api/benchmark.ts +45 -0
  54. package/src/api/config.ts +24 -0
  55. package/src/api/knowledge.ts +72 -0
  56. package/src/api/projects.ts +15 -0
  57. package/src/api/sessions.ts +82 -0
  58. package/src/api/types.ts +20 -0
  59. package/src/api.ts +44 -269
  60. package/src/components/AutomationView/AutomationView.scss +5 -1
  61. package/src/components/AutomationView/RuleFormPanel.tsx +3 -2
  62. package/src/components/AutomationView/TaskList.scss +4 -6
  63. package/src/components/AutomationView/TaskList.tsx +2 -1
  64. package/src/components/AutomationView/TriggerList.scss +4 -1
  65. package/src/components/BenchmarkView/BenchmarkCasePanel.scss +267 -0
  66. package/src/components/BenchmarkView/BenchmarkCasePanel.tsx +309 -0
  67. package/src/components/BenchmarkView/BenchmarkSidebar.scss +182 -0
  68. package/src/components/BenchmarkView/BenchmarkSidebar.tsx +262 -0
  69. package/src/components/BenchmarkView/BenchmarkView.scss +78 -0
  70. package/src/components/BenchmarkView/index.tsx +197 -0
  71. package/src/components/BenchmarkView/types.ts +10 -0
  72. package/src/components/BenchmarkView/utils.ts +21 -0
  73. package/src/components/Chat.tsx +37 -29
  74. package/src/components/{chat/CodeBlock.tsx → CodeBlock.tsx} +3 -1
  75. package/src/components/ConfigView.tsx +7 -0
  76. package/src/components/{chat/MarkdownContent.tsx → MarkdownContent.tsx} +1 -1
  77. package/src/components/NavRail.tsx +7 -0
  78. package/src/components/chat/ChatHeader.scss +37 -19
  79. package/src/components/chat/ChatHeader.tsx +6 -9
  80. package/src/components/chat/ChatHistoryView.tsx +89 -45
  81. package/src/components/chat/CurrentTodoList.tsx +10 -9
  82. package/src/components/chat/{MessageItem.scss → Messages/MessageItem.scss} +14 -0
  83. package/src/components/chat/{MessageItem.tsx → Messages/MessageItem.tsx} +30 -8
  84. package/src/components/chat/{messageUtils.ts → Messages/message-utils.ts} +1 -1
  85. package/src/components/chat/{Sender.scss → Sender/Sender.scss} +80 -0
  86. package/src/components/chat/{Sender.tsx → Sender/Sender.tsx} +161 -5
  87. package/src/components/chat/tools/DefaultTool.tsx +184 -21
  88. package/src/components/chat/tools/adapter-claude/BashTool.scss +67 -51
  89. package/src/components/chat/tools/adapter-claude/BashTool.tsx +83 -49
  90. package/src/components/chat/tools/adapter-claude/GlobTool.scss +0 -79
  91. package/src/components/chat/tools/adapter-claude/GlobTool.tsx +16 -36
  92. package/src/components/chat/tools/adapter-claude/GrepTool.scss +0 -87
  93. package/src/components/chat/tools/adapter-claude/GrepTool.tsx +22 -41
  94. package/src/components/chat/tools/adapter-claude/LSTool.scss +0 -79
  95. package/src/components/chat/tools/adapter-claude/LSTool.tsx +15 -15
  96. package/src/components/chat/tools/adapter-claude/ReadTool.scss +0 -55
  97. package/src/components/chat/tools/adapter-claude/ReadTool.tsx +20 -42
  98. package/src/components/chat/tools/adapter-claude/TodoTool.scss +8 -23
  99. package/src/components/chat/tools/adapter-claude/TodoTool.tsx +24 -11
  100. package/src/components/chat/tools/adapter-claude/WriteTool.scss +21 -69
  101. package/src/components/chat/tools/adapter-claude/WriteTool.tsx +22 -58
  102. package/src/components/chat/tools/adapter-claude/index.ts +4 -10
  103. package/src/components/chat/tools/adapter-claude/utils.ts +54 -0
  104. package/src/components/chat/tools/core/ToolCallBox.scss +356 -0
  105. package/src/components/chat/{ToolGroup.tsx → tools/core/ToolGroup.tsx} +26 -7
  106. package/src/components/chat/{ToolRenderer.tsx → tools/core/ToolRenderer.tsx} +6 -4
  107. package/src/components/chat/tools/plugin-chrome-devtools/ChromeDevtoolsTool.scss +11 -0
  108. package/src/components/chat/tools/plugin-chrome-devtools/ChromeDevtoolsTool.tsx +75 -0
  109. package/src/components/chat/tools/plugin-chrome-devtools/index.ts +45 -0
  110. package/src/components/chat/tools/task/GetTaskInfoTool.scss +2 -27
  111. package/src/components/chat/tools/task/GetTaskInfoTool.tsx +48 -38
  112. package/src/components/chat/tools/task/ListTasksTool.scss +3 -28
  113. package/src/components/chat/tools/task/ListTasksTool.tsx +11 -8
  114. package/src/components/chat/tools/task/StartTasksTool.scss +3 -28
  115. package/src/components/chat/tools/task/StartTasksTool.tsx +14 -17
  116. package/src/components/chat/tools/task/components/TaskRow.scss +105 -0
  117. package/src/components/chat/tools/task/components/TaskRow.tsx +163 -0
  118. package/src/components/chat/tools/task/components/TaskToolCard.scss +15 -15
  119. package/src/components/chat/tools/task/components/TaskToolCard.tsx +8 -6
  120. package/src/components/config/ConfigSectionForm.tsx +12 -1
  121. package/src/components/config/channelDefinitions.ts +6 -0
  122. package/src/components/config/configSchema.ts +10 -1
  123. package/src/components/config/recordEditors/ChannelRecordEditor.scss +1 -0
  124. package/src/components/config/recordEditors/ChannelRecordEditor.tsx +397 -0
  125. package/src/components/config/recordEditors/index.tsx +1 -0
  126. package/src/components/knowledge-base/components/RuleItem.tsx +1 -1
  127. package/src/components/knowledge-base/components/SpecItem.tsx +1 -1
  128. package/src/hooks/chat/use-chat-interaction.ts +26 -0
  129. package/src/{components/chat/useChatModels.tsx → hooks/chat/use-chat-models.tsx} +46 -15
  130. package/src/hooks/chat/use-chat-permission-mode.ts +47 -0
  131. package/src/hooks/chat/use-chat-scroll.ts +51 -0
  132. package/src/hooks/chat/use-chat-session-actions.ts +147 -0
  133. package/src/hooks/chat/use-chat-session-messages.ts +250 -0
  134. package/src/hooks/chat/use-chat-session.ts +57 -0
  135. package/src/hooks/chat/use-chat-view.ts +39 -0
  136. package/src/main.tsx +10 -13
  137. package/src/resources/locales/en.json +66 -0
  138. package/src/resources/locales/zh.json +66 -0
  139. package/src/runtime-config.ts +52 -0
  140. package/src/vite-env.d.ts +11 -0
  141. package/src/ws.ts +5 -3
  142. package/vite.config.ts +12 -4
  143. package/dist/assets/channel-jbCEHqbG.js +0 -1
  144. package/dist/assets/clone-CCRKqS4L.js +0 -1
  145. package/dist/assets/flowDiagram-v2-4f6560a1-Baslbgn4.js +0 -1
  146. package/dist/assets/index-B0qfCb1G.css +0 -1
  147. package/dist/assets/index-CNo75dYr.js +0 -497
  148. package/src/components/chat/ToolCallBox.scss +0 -137
  149. package/src/components/chat/useChatSession.ts +0 -370
  150. /package/src/components/{chat/CodeBlock.scss → CodeBlock.scss} +0 -0
  151. /package/src/components/chat/{MessageFooter.tsx → Messages/MessageFooter.tsx} +0 -0
  152. /package/src/components/chat/{CompletionMenu.scss → Sender/CompletionMenu.scss} +0 -0
  153. /package/src/components/chat/{CompletionMenu.tsx → Sender/CompletionMenu.tsx} +0 -0
  154. /package/src/components/chat/{ThinkingStatus.scss → Sender/ThinkingStatus.scss} +0 -0
  155. /package/src/components/chat/{ThinkingStatus.tsx → Sender/ThinkingStatus.tsx} +0 -0
  156. /package/src/components/chat/{ToolCallBox.tsx → tools/core/ToolCallBox.tsx} +0 -0
  157. /package/src/components/chat/{ToolGroup.scss → tools/core/ToolGroup.scss} +0 -0
  158. /package/src/{components/chat/safeSerialize.ts → utils/safe-serialize.ts} +0 -0
@@ -1,12 +1,15 @@
1
- import React, { useMemo } from 'react'
1
+ import React, { useEffect, useMemo, useRef } from 'react'
2
2
 
3
- import type { AskUserQuestionParams, ChatMessage, Session, SessionInfo } from '@vibe-forge/core'
3
+ import type { AskUserQuestionParams, ChatMessage, ChatMessageContent, Session, SessionInfo } from '@vibe-forge/core'
4
4
  import { CurrentTodoList } from './CurrentTodoList'
5
- import { MessageItem } from './MessageItem'
5
+ import { MessageItem } from './Messages/MessageItem'
6
6
  import { NewSessionGuide } from './NewSessionGuide'
7
- import { Sender } from './Sender'
8
- import { ToolGroup } from './ToolGroup'
9
- import { processMessages } from './messageUtils'
7
+ import { Sender } from './Sender/Sender'
8
+ import { ToolGroup } from './tools/core/ToolGroup'
9
+ import { processMessages } from './Messages/message-utils'
10
+ import type { PermissionMode } from '#~/hooks/chat/use-chat-permission-mode'
11
+ import { useChatScroll } from '#~/hooks/chat/use-chat-scroll'
12
+ import { useChatSessionActions } from '#~/hooks/chat/use-chat-session-actions'
10
13
 
11
14
  interface ModelSelectOption {
12
15
  value: string
@@ -24,68 +27,105 @@ export function ChatHistoryView({
24
27
  messages,
25
28
  session,
26
29
  sessionInfo,
27
- isCreating,
28
- showScrollBottom,
29
- messagesContainerRef,
30
- messagesEndRef,
31
- scrollToBottom,
32
30
  interactionRequest,
33
31
  onInteractionResponse,
32
+ onClearMessages,
34
33
  onSend,
35
- onInterrupt,
36
- onClear,
34
+ onSendContent,
37
35
  placeholder,
38
36
  modelOptions,
39
37
  selectedModel,
38
+ modelForQuery,
40
39
  onModelChange,
41
- modelUnavailable
40
+ permissionMode,
41
+ permissionModeOptions,
42
+ onPermissionModeChange,
43
+ modelUnavailable,
44
+ hasAvailableModels
42
45
  }: {
43
46
  isReady: boolean
44
47
  messages: ChatMessage[]
45
48
  session?: Session
46
49
  sessionInfo: SessionInfo | null
47
- isCreating: boolean
48
- showScrollBottom: boolean
49
- messagesContainerRef: React.RefObject<HTMLDivElement>
50
- messagesEndRef: React.RefObject<HTMLDivElement>
51
- scrollToBottom: (behavior?: ScrollBehavior) => void
52
50
  interactionRequest: { id: string; payload: AskUserQuestionParams } | null
53
51
  onInteractionResponse: (id: string, data: string | string[]) => void
52
+ onClearMessages: () => void
54
53
  onSend: (text: string) => void
55
- onInterrupt: () => void
56
- onClear: () => void
54
+ onSendContent: (content: ChatMessageContent[]) => void
57
55
  placeholder?: string
58
56
  modelOptions: ModelSelectGroup[]
59
57
  selectedModel?: string
58
+ modelForQuery?: string
60
59
  onModelChange: (model: string) => void
60
+ permissionMode: PermissionMode
61
+ permissionModeOptions: Array<{ value: PermissionMode; label: React.ReactNode }>
62
+ onPermissionModeChange: (mode: PermissionMode) => void
61
63
  modelUnavailable: boolean
64
+ hasAvailableModels: boolean
62
65
  }) {
66
+ const { messagesEndRef, messagesContainerRef, messagesContentRef, showScrollBottom, scrollToBottom } = useChatScroll({
67
+ messagesLength: messages.length
68
+ })
69
+ const { isCreating, send, sendContent, interrupt, clearMessages } = useChatSessionActions({
70
+ session,
71
+ modelForQuery,
72
+ hasAvailableModels,
73
+ permissionMode,
74
+ onClearMessages
75
+ })
76
+ const initialScrollDoneRef = useRef(false)
77
+ const handleSend = async (text: string) => {
78
+ await send(text)
79
+ if (session?.id) {
80
+ onSend(text)
81
+ }
82
+ }
83
+ const handleSendContent = async (content: ChatMessageContent[]) => {
84
+ await sendContent(content)
85
+ onSendContent(content)
86
+ }
87
+ useEffect(() => {
88
+ initialScrollDoneRef.current = false
89
+ }, [session?.id])
90
+ useEffect(() => {
91
+ if (!initialScrollDoneRef.current && isReady) {
92
+ scrollToBottom('auto')
93
+ initialScrollDoneRef.current = true
94
+ }
95
+ }, [isReady, messages.length, scrollToBottom])
96
+ useEffect(() => {
97
+ if (!showScrollBottom) {
98
+ scrollToBottom('auto')
99
+ }
100
+ }, [messages.length, scrollToBottom, showScrollBottom])
63
101
  const renderItems = useMemo(() => processMessages(messages), [messages])
64
102
 
65
103
  return (
66
104
  <>
67
105
  <div className={`chat-messages ${isReady ? 'ready' : ''}`} ref={messagesContainerRef}>
68
- {renderItems.map((item, index) => {
69
- if (item.type === 'message') {
70
- return (
71
- <MessageItem
72
- key={item.message.id || index}
73
- msg={item.message}
74
- isFirstInGroup={item.isFirstInGroup}
75
- />
76
- )
77
- } else if (item.type === 'tool-group') {
78
- return (
79
- <ToolGroup
80
- key={item.id || `group-${index}`}
81
- items={item.items}
82
- footer={item.footer}
83
- />
84
- )
85
- }
86
- return null
87
- })}
88
- <div ref={messagesEndRef} />
106
+ <div className='chat-messages-content' ref={messagesContentRef}>
107
+ {renderItems.map((item, index) => {
108
+ if (item.type === 'message') {
109
+ return (
110
+ <MessageItem
111
+ key={item.message.id || index}
112
+ msg={item.message}
113
+ isFirstInGroup={item.isFirstInGroup}
114
+ />
115
+ )
116
+ } else if (item.type === 'tool-group') {
117
+ return (
118
+ <ToolGroup
119
+ key={item.id || `group-${index}`}
120
+ items={item.items}
121
+ footer={item.footer}
122
+ />
123
+ )
124
+ }
125
+ return null
126
+ })}
127
+ <div ref={messagesEndRef} />
128
+ </div>
89
129
 
90
130
  {showScrollBottom && (
91
131
  <div className='scroll-bottom-btn' onClick={() => scrollToBottom()}>
@@ -103,10 +143,11 @@ export function ChatHistoryView({
103
143
  <CurrentTodoList messages={messages} />
104
144
  <div className='sender-container'>
105
145
  <Sender
106
- onSend={onSend}
146
+ onSend={handleSend}
147
+ onSendContent={handleSendContent}
107
148
  sessionStatus={isCreating ? 'running' : session?.status}
108
- onInterrupt={onInterrupt}
109
- onClear={onClear}
149
+ onInterrupt={interrupt}
150
+ onClear={clearMessages}
110
151
  sessionInfo={sessionInfo}
111
152
  interactionRequest={interactionRequest}
112
153
  onInteractionResponse={onInteractionResponse}
@@ -114,6 +155,9 @@ export function ChatHistoryView({
114
155
  modelOptions={modelOptions}
115
156
  selectedModel={selectedModel}
116
157
  onModelChange={onModelChange}
158
+ permissionMode={permissionMode}
159
+ permissionModeOptions={permissionModeOptions}
160
+ onPermissionModeChange={onPermissionModeChange}
117
161
  modelUnavailable={modelUnavailable}
118
162
  />
119
163
  </div>
@@ -1,14 +1,10 @@
1
1
  import './CurrentTodoList.scss'
2
- import type { ChatMessage } from '@vibe-forge/core'
3
2
  import React, { useState } from 'react'
4
3
  import { useTranslation } from 'react-i18next'
4
+ import type { ChatMessage } from '@vibe-forge/core'
5
+ import type { ToolInputs } from '@vibe-forge/core'
5
6
 
6
- interface TodoItem {
7
- id: string
8
- content: string
9
- status: 'pending' | 'in_progress' | 'completed'
10
- priority: string
11
- }
7
+ type TodoItem = ToolInputs['adapter:claude-code:TodoWrite']['todos'][number]
12
8
 
13
9
  export function CurrentTodoList({ messages }: { messages: ChatMessage[] }) {
14
10
  const { t } = useTranslation()
@@ -21,12 +17,17 @@ export function CurrentTodoList({ messages }: { messages: ChatMessage[] }) {
21
17
  const msg = messages[i]
22
18
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
23
19
  const todoUse = msg.content.find(c =>
24
- c != null && c.type === 'tool_use' && (c.name === 'TodoWrite' || c.name === 'todo_write')
20
+ c != null && c.type === 'tool_use' && (
21
+ c.name === 'TodoWrite' ||
22
+ c.name === 'todo_write' ||
23
+ c.name === 'adapter:claude-code:TodoWrite' ||
24
+ c.name === 'adapter:claude-code:todo_write'
25
+ )
25
26
  )
26
27
  if (
27
28
  todoUse != null && todoUse.type === 'tool_use' && todoUse.input != null && typeof todoUse.input === 'object'
28
29
  ) {
29
- const input = todoUse.input as { todos?: TodoItem[] }
30
+ const input = todoUse.input as Partial<ToolInputs['adapter:claude-code:TodoWrite']>
30
31
  if (Array.isArray(input.todos)) {
31
32
  latestTodos = input.todos
32
33
  break
@@ -122,6 +122,20 @@ html.dark {
122
122
  width: 100%;
123
123
  }
124
124
 
125
+ .message-image {
126
+ display: inline-block;
127
+ max-width: 360px;
128
+ border-radius: 10px;
129
+ overflow: hidden;
130
+ border: 1px solid var(--border-color);
131
+ }
132
+
133
+ .message-image img {
134
+ display: block;
135
+ max-width: 100%;
136
+ height: auto;
137
+ }
138
+
125
139
  .markdown-body {
126
140
  --border-color: #e5e7eb;
127
141
  --code-block-bg: #ffffff;
@@ -2,16 +2,18 @@ import './MessageItem.scss'
2
2
  import type { ChatMessage } from '@vibe-forge/core'
3
3
  import React from 'react'
4
4
  import { MessageFooter } from './MessageFooter'
5
- import { MarkdownContent } from './MarkdownContent'
6
- import { ToolRenderer } from './ToolRenderer'
5
+ import { MarkdownContent } from '#~/components/MarkdownContent'
6
+ import { ToolRenderer } from '../tools/core/ToolRenderer'
7
7
 
8
- export function MessageItem({
9
- msg,
10
- isFirstInGroup
11
- }: {
8
+ type MessageItemProps = {
12
9
  msg: ChatMessage
13
10
  isFirstInGroup: boolean
14
- }) {
11
+ }
12
+
13
+ function MessageItemComponent({
14
+ msg,
15
+ isFirstInGroup
16
+ }: MessageItemProps) {
15
17
  const isUser = msg.role === 'user'
16
18
 
17
19
  const renderContent = () => {
@@ -25,7 +27,7 @@ export function MessageItem({
25
27
 
26
28
  if (!Array.isArray(msg.content)) return null
27
29
 
28
- const hasContent = msg.content.some(c => c.type === 'text') || msg.toolCall != null
30
+ const hasContent = msg.content.some(c => c.type === 'text' || c.type === 'image') || msg.toolCall != null
29
31
  if (!hasContent) return null
30
32
 
31
33
  return (
@@ -36,6 +38,13 @@ export function MessageItem({
36
38
  <MarkdownContent key={i} content={item.text} />
37
39
  )
38
40
  }
41
+ if (item.type === 'image') {
42
+ return (
43
+ <a key={i} className='message-image' href={item.url} target='_blank' rel='noreferrer'>
44
+ <img src={item.url} alt={item.name ?? 'image'} />
45
+ </a>
46
+ )
47
+ }
39
48
  return null
40
49
  })}
41
50
  {msg.toolCall != null && (
@@ -76,3 +85,16 @@ export function MessageItem({
76
85
  </div>
77
86
  )
78
87
  }
88
+
89
+ const areMessageItemPropsEqual = (prev: MessageItemProps, next: MessageItemProps) => {
90
+ return prev.isFirstInGroup === next.isFirstInGroup
91
+ && prev.msg.id === next.msg.id
92
+ && prev.msg.role === next.msg.role
93
+ && prev.msg.createdAt === next.msg.createdAt
94
+ && prev.msg.model === next.msg.model
95
+ && prev.msg.content === next.msg.content
96
+ && prev.msg.toolCall === next.msg.toolCall
97
+ && prev.msg.usage === next.msg.usage
98
+ }
99
+
100
+ export const MessageItem = React.memo(MessageItemComponent, areMessageItemPropsEqual)
@@ -114,7 +114,7 @@ export function processMessages(messages: ChatMessage[]): ChatRenderItem[] {
114
114
  }
115
115
 
116
116
  for (const item of content) {
117
- if (item.type === 'text') {
117
+ if (item.type === 'text' || item.type === 'image') {
118
118
  flushTools()
119
119
  textParts.push(item)
120
120
  } else if (item.type === 'tool_use') {
@@ -39,6 +39,54 @@
39
39
  }
40
40
  }
41
41
 
42
+ .file-input-hidden {
43
+ display: none;
44
+ }
45
+
46
+ .pending-images {
47
+ display: flex;
48
+ gap: 8px;
49
+ padding: 6px 0;
50
+ flex-wrap: wrap;
51
+ }
52
+
53
+ .pending-image {
54
+ position: relative;
55
+ width: 56px;
56
+ height: 56px;
57
+ border-radius: 10px;
58
+ overflow: hidden;
59
+ border: 1px solid var(--border-color);
60
+ background-color: var(--bg-color);
61
+
62
+ img {
63
+ width: 100%;
64
+ height: 100%;
65
+ object-fit: cover;
66
+ display: block;
67
+ }
68
+ }
69
+
70
+ .pending-image-remove {
71
+ position: absolute;
72
+ top: 4px;
73
+ right: 4px;
74
+ width: 18px;
75
+ height: 18px;
76
+ border-radius: 9px;
77
+ background-color: rgba(0, 0, 0, .55);
78
+ color: #fff;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ cursor: pointer;
83
+
84
+ .material-symbols-rounded {
85
+ font-size: 14px;
86
+ line-height: 1;
87
+ }
88
+ }
89
+
42
90
  .chat-input-toolbar {
43
91
  display: flex;
44
92
  justify-content: space-between;
@@ -245,6 +293,38 @@
245
293
  }
246
294
  }
247
295
 
296
+ .permission-mode-select {
297
+ min-width: 120px;
298
+
299
+ .ant-select-selector {
300
+ height: 28px !important;
301
+ border-radius: 8px !important;
302
+ border: 1px solid var(--border-color) !important;
303
+ background-color: var(--tag-hover-bg, #f3f4f6) !important;
304
+ padding: 0 8px !important;
305
+ display: flex;
306
+ align-items: center;
307
+ }
308
+
309
+ .ant-select-selection-item,
310
+ .ant-select-selection-placeholder {
311
+ font-size: 12px;
312
+ color: var(--text-color);
313
+ line-height: 1;
314
+ }
315
+
316
+ &.ant-select-disabled .ant-select-selector {
317
+ background-color: var(--tag-bg, #f9fafb) !important;
318
+ color: var(--sub-text-color, #9ca3af);
319
+ }
320
+ }
321
+
322
+ .permission-mode-select-popup {
323
+ .ant-select-item-option-content {
324
+ font-size: 12px;
325
+ }
326
+ }
327
+
248
328
  .model-select-popup {
249
329
  .ant-select-item-group {
250
330
  padding: 8px 8px 4px;
@@ -6,11 +6,12 @@ import React, { useEffect, useRef, useState } from 'react'
6
6
  import { useTranslation } from 'react-i18next'
7
7
  import useSWR from 'swr'
8
8
 
9
- import type { AskUserQuestionParams, SessionInfo, SessionStatus } from '@vibe-forge/core'
9
+ import type { AskUserQuestionParams, ChatMessageContent, SessionInfo, SessionStatus } from '@vibe-forge/core'
10
10
  import type { CompletionItem } from './CompletionMenu'
11
+ import type { PermissionMode } from '#~/hooks/chat/use-chat-permission-mode'
11
12
  import { CompletionMenu } from './CompletionMenu'
12
13
  import { ThinkingStatus } from './ThinkingStatus'
13
- import { isShortcutMatch } from '../../utils/shortcutUtils'
14
+ import { isShortcutMatch } from '../../../utils/shortcutUtils'
14
15
 
15
16
  const { TextArea } = Input
16
17
 
@@ -25,8 +26,17 @@ interface ModelSelectGroup {
25
26
  options: ModelSelectOption[]
26
27
  }
27
28
 
29
+ interface PendingImage {
30
+ id: string
31
+ url: string
32
+ name?: string
33
+ size?: number
34
+ mimeType?: string
35
+ }
36
+
28
37
  export function Sender({
29
38
  onSend,
39
+ onSendContent,
30
40
  sessionStatus,
31
41
  onInterrupt,
32
42
  onClear,
@@ -37,9 +47,13 @@ export function Sender({
37
47
  modelOptions,
38
48
  selectedModel,
39
49
  onModelChange,
50
+ permissionMode,
51
+ permissionModeOptions,
52
+ onPermissionModeChange,
40
53
  modelUnavailable
41
54
  }: {
42
55
  onSend: (text: string) => void
56
+ onSendContent: (content: ChatMessageContent[]) => void
43
57
  sessionStatus?: SessionStatus
44
58
  onInterrupt: () => void
45
59
  onClear?: () => void
@@ -50,6 +64,9 @@ export function Sender({
50
64
  modelOptions?: ModelSelectGroup[]
51
65
  selectedModel?: string
52
66
  onModelChange?: (model: string) => void
67
+ permissionMode: PermissionMode
68
+ permissionModeOptions: Array<{ value: PermissionMode; label: React.ReactNode }>
69
+ onPermissionModeChange: (mode: PermissionMode) => void
53
70
  modelUnavailable?: boolean
54
71
  }) {
55
72
  const { t } = useTranslation()
@@ -63,7 +80,9 @@ export function Sender({
63
80
  const [showToolsList, setShowToolsList] = useState(false)
64
81
  const textareaRef = useRef<TextAreaRef>(null)
65
82
  const toolsRef = useRef<HTMLDivElement>(null)
83
+ const fileInputRef = useRef<HTMLInputElement>(null)
66
84
  const isMac = navigator.platform.includes('Mac')
85
+ const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
67
86
 
68
87
  const { data: configRes } = useSWR<{
69
88
  sources?: {
@@ -96,8 +115,54 @@ export function Sender({
96
115
  const [historyIndex, setHistoryIndex] = useState(-1)
97
116
  const [draft, setDraft] = useState('')
98
117
 
118
+ const readFileAsDataUrl = (file: File) => {
119
+ return new Promise<string>((resolve, reject) => {
120
+ const reader = new FileReader()
121
+ reader.onload = () => {
122
+ resolve(typeof reader.result === 'string' ? reader.result : '')
123
+ }
124
+ reader.onerror = () => {
125
+ reject(new Error('read_failed'))
126
+ }
127
+ reader.readAsDataURL(file)
128
+ })
129
+ }
130
+
131
+ const addImageFiles = async (files: File[]) => {
132
+ const maxSize = 5 * 1024 * 1024
133
+ for (const file of files) {
134
+ if (!file.type.startsWith('image/')) continue
135
+ if (file.size > maxSize) {
136
+ void message.error(t('chat.imageTooLarge'))
137
+ continue
138
+ }
139
+ try {
140
+ const url = await readFileAsDataUrl(file)
141
+ if (url === '') {
142
+ void message.error(t('chat.imageReadFailed'))
143
+ continue
144
+ }
145
+ setPendingImages(prev => [
146
+ ...prev,
147
+ {
148
+ id: globalThis.crypto?.randomUUID
149
+ ? globalThis.crypto.randomUUID()
150
+ : `img-${Date.now()}-${Math.random().toString(16).slice(2)}`,
151
+ url,
152
+ name: file.name,
153
+ size: file.size,
154
+ mimeType: file.type
155
+ }
156
+ ])
157
+ } catch (err) {
158
+ void message.error(t('chat.imageReadFailed'))
159
+ }
160
+ }
161
+ }
162
+
99
163
  const handleSend = () => {
100
- if (input.trim() === '' || isThinking) return
164
+ if (isThinking) return
165
+ if (input.trim() === '' && pendingImages.length === 0) return
101
166
 
102
167
  if (modelUnavailable) {
103
168
  void message.warning(t('chat.modelConfigRequired'))
@@ -105,12 +170,31 @@ export function Sender({
105
170
  }
106
171
 
107
172
  if (interactionRequest != null && onInteractionResponse != null) {
173
+ if (pendingImages.length > 0) {
174
+ void message.warning(t('chat.imageNotSupportedInInteraction'))
175
+ return
176
+ }
108
177
  onInteractionResponse(interactionRequest.id, input.trim())
109
178
  setInput('')
110
179
  return
111
180
  }
112
181
 
113
- onSend(input)
182
+ if (pendingImages.length > 0) {
183
+ const content: ChatMessageContent[] = []
184
+ if (input.trim() !== '') {
185
+ content.push({ type: 'text', text: input.trim() })
186
+ }
187
+ content.push(...pendingImages.map(img => ({
188
+ type: 'image',
189
+ url: img.url,
190
+ name: img.name,
191
+ size: img.size,
192
+ mimeType: img.mimeType
193
+ })))
194
+ onSendContent(content)
195
+ } else {
196
+ onSend(input)
197
+ }
114
198
 
115
199
  // Save to local storage history
116
200
  try {
@@ -122,11 +206,48 @@ export function Sender({
122
206
  }
123
207
 
124
208
  setInput('')
209
+ setPendingImages([])
125
210
  setDraft('')
126
211
  setShowCompletion(false)
127
212
  setHistoryIndex(-1)
128
213
  }
129
214
 
215
+ const handleImageUpload = () => {
216
+ if (isThinking) return
217
+ if (modelUnavailable) {
218
+ void message.warning(t('chat.modelConfigRequired'))
219
+ return
220
+ }
221
+ if (interactionRequest != null) {
222
+ void message.warning(t('chat.imageNotSupportedInInteraction'))
223
+ return
224
+ }
225
+ fileInputRef.current?.click()
226
+ }
227
+
228
+ const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
229
+ const fileList = Array.from(event.target.files ?? [])
230
+ if (fileList.length === 0) return
231
+ await addImageFiles(fileList)
232
+ event.target.value = ''
233
+ }
234
+
235
+ const handleRemovePendingImage = (id: string) => {
236
+ setPendingImages(prev => prev.filter(img => img.id !== id))
237
+ }
238
+
239
+ const handlePaste = async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
240
+ const items = Array.from(event.clipboardData?.items ?? [])
241
+ const files = items
242
+ .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
243
+ .map(item => item.getAsFile())
244
+ .filter((file): file is File => file != null)
245
+ if (files.length > 0) {
246
+ event.preventDefault()
247
+ await addImageFiles(files)
248
+ }
249
+ }
250
+
130
251
  const clearInputValue = () => {
131
252
  if (input === '') return
132
253
  setInput('')
@@ -422,6 +543,18 @@ export function Sender({
422
543
  {t('chat.modelConfigRequired')}
423
544
  </div>
424
545
  )}
546
+ {pendingImages.length > 0 && (
547
+ <div className='pending-images'>
548
+ {pendingImages.map(img => (
549
+ <div key={img.id} className='pending-image'>
550
+ <img src={img.url} alt={img.name ?? 'image'} />
551
+ <div className='pending-image-remove' onClick={() => handleRemovePendingImage(img.id)}>
552
+ <span className='material-symbols-rounded'>close</span>
553
+ </div>
554
+ </div>
555
+ ))}
556
+ </div>
557
+ )}
425
558
  {showCompletion && (
426
559
  <CompletionMenu
427
560
  items={completionItems}
@@ -437,12 +570,21 @@ export function Sender({
437
570
  value={input}
438
571
  onChange={handleInputChange}
439
572
  onKeyDown={handleKeyDown}
573
+ onPaste={handlePaste}
440
574
  autoSize={{ minRows: 1, maxRows: 10 }}
441
575
  variant='borderless'
442
576
  disabled={modelUnavailable}
443
577
  />
444
578
 
445
579
  <div className='chat-input-toolbar'>
580
+ <input
581
+ ref={fileInputRef}
582
+ type='file'
583
+ accept='image/*'
584
+ multiple
585
+ onChange={handleImageFileChange}
586
+ className='file-input-hidden'
587
+ />
446
588
  <div className='toolbar-left'>
447
589
  <Tooltip title='快捷指令'>
448
590
  <span>
@@ -467,7 +609,7 @@ export function Sender({
467
609
  </Tooltip>
468
610
  <Tooltip title='上传图片'>
469
611
  <span>
470
- <div className='toolbar-btn' onClick={() => void message.info('图片上传功能尚不支持')}>
612
+ <div className='toolbar-btn' onClick={handleImageUpload}>
471
613
  <span className='material-symbols-rounded'>image</span>
472
614
  </div>
473
615
  </span>
@@ -522,6 +664,20 @@ export function Sender({
522
664
  popupMatchSelectWidth={false}
523
665
  />
524
666
 
667
+ <Select
668
+ className='permission-mode-select'
669
+ classNames={{ popup: { root: 'permission-mode-select-popup' } }}
670
+ value={permissionMode}
671
+ options={permissionModeOptions}
672
+ showSearch={false}
673
+ allowClear={false}
674
+ disabled={modelUnavailable || isThinking}
675
+ onChange={(value) => onPermissionModeChange(value)}
676
+ placeholder='权限模式'
677
+ optionLabelProp='label'
678
+ popupMatchSelectWidth={false}
679
+ />
680
+
525
681
  <div
526
682
  className={`chat-send-btn ${input.trim() !== '' && !modelUnavailable ? 'active' : ''} ${isThinking ? 'thinking' : ''} ${modelUnavailable ? 'disabled' : ''}`}
527
683
  onClick={modelUnavailable ? undefined : (isThinking ? onInterrupt : handleSend)}