Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proma/electron",
"version": "0.15.11",
"version": "0.15.12",
"description": "Proma next gen ai software with general agents - Electron App",
"main": "dist/main.cjs",
"author": {
Expand Down
4 changes: 2 additions & 2 deletions apps/electron/src/main/lib/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,14 +845,14 @@ export class AgentOrchestrator {
* 通过 EventBus 分发 AgentEvent,通过 callbacks 发送控制信号。
*/
async sendMessage(input: AgentSendInput, callbacks: SessionCallbacks): Promise<void> {
const { sessionId, userMessage, channelId, modelId, agentRuntime: inputAgentRuntime, workspaceId, additionalDirectories, customMcpServers, permissionModeOverride, mentionedSkills, mentionedMcpServers, mentionedSessionIds, automationContext, retryOfErrorUuid } = input
const { sessionId, userMessage, rawUserMessage, channelId, modelId, agentRuntime: inputAgentRuntime, workspaceId, additionalDirectories, customMcpServers, permissionModeOverride, mentionedSkills, mentionedMcpServers, mentionedSessionIds, automationContext, retryOfErrorUuid } = input
const stderrChunks: string[] = []
const streamStartedAt = input.startedAt ?? Date.now()
let userMessagePersisted = false

const persistInitialUserMessage = (): void => {
if (userMessagePersisted) return
this.persistUserMessage(sessionId, userMessage)
this.persistUserMessage(sessionId, rawUserMessage ?? userMessage)
userMessagePersisted = true
callbacks.onRunStarted?.({ startedAt: streamStartedAt })
}
Expand Down
3 changes: 3 additions & 0 deletions apps/electron/src/renderer/atoms/agent-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { AgentSessionMeta, AgentEvent, AgentWorkspace, AgentPendingFile, Re
import { PROMA_DEFAULT_PERMISSION_MODE } from '@proma/shared'
import { calculateDockBadgeCount, countPendingRequests } from '@/lib/dock-badge-count'
import type { AgentQueuedMessage } from '@/lib/agent-message-queue'
import type { AgentQuoteFocus } from '@/lib/agent-quote-reference'

/** 活动状态 */
export type ActivityStatus = 'pending' | 'running' | 'completed' | 'error' | 'backgrounded'
Expand Down Expand Up @@ -230,6 +231,8 @@ export const agentSessionChannelMapAtom = atom<Map<string, string>>(new Map())
/** Per-session 模型 ID Map — sessionId → modelId */
export const agentSessionModelMapAtom = atom<Map<string, string>>(new Map())
export const currentAgentSessionIdAtom = atom<string | null>(null)
/** 下一次打开 Agent 会话时需要滚动并高亮的消息。 */
export const agentQuoteFocusAtom = atom<AgentQuoteFocus | null>(null)
export const agentStreamingStatesAtom = atom<Map<string, AgentStreamState>>(new Map())

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import {
selectedModelAtom,
} from '@/atoms/chat-atoms'
import { quotedSelectionMapAtom } from '@/atoms/preview-atoms'
import { agentDiffPanelTabAtom, agentSidePanelOpenAtom } from '@/atoms/agent-atoms'
import { agentDiffPanelTabAtom, agentSessionsAtom, agentSidePanelOpenAtom } from '@/atoms/agent-atoms'
import { SelectionActionPopover } from '@/components/selection/SelectionActionPopover'
import { SELECTION_ACTION_POPOVER_SELECTOR } from '@/lib/quoted-selection'
import { copyAgentQuoteReference } from '@/lib/agent-quote-reference'

const MAX_AGENT_HISTORY_QUOTED_CHARS = 2000

Expand All @@ -27,6 +28,8 @@ interface AgentHistorySelection {
x: number
y: number
sourceLabel: string
sessionTitle: string
turn: number
messageId?: string
messageRole?: 'user' | 'assistant' | 'system'
}
Expand All @@ -52,12 +55,24 @@ function getRoleLabel(role?: string): string {
return 'Agent 历史'
}

function getMessageTurn(root: HTMLDivElement, target: Element): number {
const messages = Array.from(root.querySelectorAll('[data-message-id]'))
const targetIndex = messages.indexOf(target)
if (targetIndex < 0) return 1
const turns = messages
.slice(0, targetIndex + 1)
.filter((message) => message.getAttribute('data-message-role') === 'user')
.length
return turns || targetIndex + 1
}

export function AgentHistorySelectionLayer({
sessionId,
rootRef,
}: AgentHistorySelectionLayerProps): React.ReactElement {
const setQuotedSelectionMap = useSetAtom(quotedSelectionMapAtom)
const selectedChatModel = useAtomValue(selectedModelAtom)
const agentSessions = useAtomValue(agentSessionsAtom)
const setConversations = useSetAtom(conversationsAtom)
const setConversationDrafts = useSetAtom(conversationDraftsAtom)
const setSideChatMap = useSetAtom(agentSideChatMapAtom)
Expand Down Expand Up @@ -118,11 +133,14 @@ export function AgentHistorySelectionLayer({
: null
const messageId = sameMessage ? startMessageEl.getAttribute('data-message-id') ?? undefined : undefined

const sessionTitle = agentSessions.find((session) => session.id === sessionId)?.title ?? '未命名 Agent 会话'
setSelection({
text,
x: anchorRect.left + anchorRect.width / 2,
y: Math.max(12, anchorRect.top - 12),
sourceLabel: sameMessage ? getRoleLabel(role ?? undefined) : 'Agent 历史 · 多条消息',
sessionTitle,
turn: sameMessage ? getMessageTurn(root, startMessageEl) : 1,
messageId,
messageRole: role ?? undefined,
})
Expand All @@ -133,7 +151,7 @@ export function AgentHistorySelectionLayer({
duration: 3000,
})
}
}, [clearSelection, rootRef, sessionId])
}, [agentSessions, clearSelection, rootRef, sessionId])

const scheduleCaptureSelection = React.useCallback((): void => {
if (captureTimerRef.current != null) {
Expand Down Expand Up @@ -212,6 +230,31 @@ export function AgentHistorySelectionLayer({
toast.success('已添加到 Agent 引用')
}, [clearSelection, selection, sessionId, setQuotedSelectionMap])

const handleCopyAsQuote = React.useCallback(async (): Promise<void> => {
if (!selection?.messageId) {
toast.error('引用块暂不支持跨消息选区')
return
}
try {
await copyAgentQuoteReference({
version: 1,
sessionId,
messageId: selection.messageId,
sessionTitle: selection.sessionTitle,
turn: selection.turn,
messageRole: selection.messageRole ?? 'assistant',
text: selection.text,
capturedAt: Date.now(),
})
window.getSelection()?.removeAllRanges()
clearSelection()
toast.success('已复制为引用块')
} catch (error) {
console.error('[AgentMessages] 复制引用块失败:', error)
toast.error('复制引用块失败')
}
}, [clearSelection, selection, sessionId])

const handleOpenChatTab = React.useCallback(async (): Promise<void> => {
if (!selection) return
if (openChatPendingRef.current) return
Expand Down Expand Up @@ -283,6 +326,7 @@ export function AgentHistorySelectionLayer({
x={selection.x}
y={selection.y}
onAddToAgent={handleAddToAgent}
onCopyAsQuote={handleCopyAsQuote}
onOpenChat={handleOpenChatTab}
/>
)}
Expand Down
47 changes: 46 additions & 1 deletion apps/electron/src/renderer/components/agent/AgentMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { AgentHistorySelectionLayer } from './AgentHistorySelectionLayer'
import { TaskProgressOverlay, type ContextCompactionProgress } from './TaskProgressOverlay'
import type { AgentEventUsage, RetryAttempt, SDKMessage, SDKSystemMessage } from '@proma/shared'
import { getSDKCompactStatus } from '@proma/shared'
import type { AgentStreamState } from '@/atoms/agent-atoms'
import { agentQuoteFocusAtom, type AgentStreamState } from '@/atoms/agent-atoms'

function stableStringify(value: unknown): string {
if (value == null || typeof value !== 'object') return JSON.stringify(value) ?? String(value)
Expand Down Expand Up @@ -477,13 +477,58 @@ export function AgentMessages({ sessionId, sessionModelId, messagesLoaded, persi
const userProfile = useAtomValue(userProfileAtom)
const setMinimapCache = useSetAtom(tabMinimapCacheAtom)
const channels = useAtomValue(channelsAtom)
const quoteFocus = useAtomValue(agentQuoteFocusAtom)
const setQuoteFocus = useSetAtom(agentQuoteFocusAtom)
const handledQuoteFocusNonceRef = React.useRef<number | null>(null)
const historySelectionRootRef = React.useRef<HTMLDivElement>(null)
/** 淡入控制:切换会话时先隐藏,等布局完成后再显示。 */
const [ready, setReady] = React.useState(false)
// 空会话无需淡入过渡(无消息则无滚动位置问题)
const [skipFadeIn, setSkipFadeIn] = React.useState(false)
const prevSessionIdRef = React.useRef<string | null>(null)

React.useEffect(() => {
if (
!quoteFocus
|| quoteFocus.sessionId !== sessionId
|| handledQuoteFocusNonceRef.current === quoteFocus.nonce
) return

let frameId: number | null = null
let highlightTimer: number | null = null
let attempts = 0
let cancelled = false
const revealTarget = (): void => {
if (cancelled) return
const root = historySelectionRootRef.current
const target = root
? Array.from(root.querySelectorAll<HTMLElement>('[data-message-id]'))
.find((node) => node.getAttribute('data-message-id') === quoteFocus.messageId)
: undefined
if (!target) {
attempts += 1
if (attempts < 36) frameId = requestAnimationFrame(revealTarget)
return
}
handledQuoteFocusNonceRef.current = quoteFocus.nonce
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.remove('agent-quote-target-highlight')
void target.offsetWidth
target.classList.add('agent-quote-target-highlight')
highlightTimer = window.setTimeout(() => {
target.classList.remove('agent-quote-target-highlight')
setQuoteFocus((current) => current?.nonce === quoteFocus.nonce ? null : current)
}, 1_900)
}

frameId = requestAnimationFrame(revealTarget)
return () => {
cancelled = true
if (frameId != null) cancelAnimationFrame(frameId)
if (highlightTimer != null) window.clearTimeout(highlightTimer)
}
}, [liveMessages, messagesLoaded, persistedSDKMessages, quoteFocus, sessionId, setQuoteFocus])

React.useEffect(() => {
if (sessionId !== prevSessionIdRef.current) {
prevSessionIdRef.current = sessionId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { toast } from 'sonner'
import { agentQuoteFocusAtom, agentSessionsAtom } from '@/atoms/agent-atoms'
import { AGENT_QUOTE_OPEN_EVENT, type AgentQuoteReference } from '@/lib/agent-quote-reference'
import { useOpenSession } from '@/hooks/useOpenSession'

/** Keeps quote-chip navigation available even while Scratch Pad is the active tab. */
export function AgentQuoteNavigationListener(): null {
const agentSessions = useAtomValue(agentSessionsAtom)
const setQuoteFocus = useSetAtom(agentQuoteFocusAtom)
const openSession = useOpenSession()

React.useEffect(() => {
const handleOpenQuote = (event: Event): void => {
const reference = (event as CustomEvent<AgentQuoteReference>).detail
if (!reference?.sessionId || !reference.messageId) return
const session = agentSessions.find((item) => item.id === reference.sessionId)
if (!session) {
toast.error('来源 Agent 会话已不存在')
return
}
setQuoteFocus({
sessionId: reference.sessionId,
messageId: reference.messageId,
nonce: Date.now(),
})
openSession('agent', session.id, session.title)
}

window.addEventListener(AGENT_QUOTE_OPEN_EVENT, handleOpenQuote)
return () => window.removeEventListener(AGENT_QUOTE_OPEN_EVENT, handleOpenQuote)
}, [agentSessions, openSession, setQuoteFocus])

return null
}
103 changes: 103 additions & 0 deletions apps/electron/src/renderer/components/agent/AgentQuoteReference.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as React from 'react'
import { Node, mergeAttributes } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from '@tiptap/react'
import { Bot } from 'lucide-react'
import {
dispatchAgentQuoteOpen,
getAgentQuoteReferenceLabel,
parseAgentQuoteReferencePayload,
type AgentQuoteReference,
} from '@/lib/agent-quote-reference'

interface AgentQuoteReferenceChipProps {
reference: AgentQuoteReference
}

export function AgentQuoteReferenceChip({ reference }: AgentQuoteReferenceChipProps): React.ReactElement {
const label = getAgentQuoteReferenceLabel(reference)
return (
<button
type="button"
className="agent-quote-reference-chip"
title={`跳转到 ${label}`}
aria-label={`跳转到 ${label}`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
dispatchAgentQuoteOpen(reference)
}}
>
<Bot className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
</button>
)
}

function AgentQuoteReferenceNodeView({ node }: NodeViewProps): React.ReactElement {
const reference = parseAgentQuoteReferencePayload(String(node.attrs.reference ?? ''))
return (
<NodeViewWrapper as="span" className="agent-quote-reference-node" contentEditable={false}>
{reference ? (
<AgentQuoteReferenceChip reference={reference} />
) : (
<span className="agent-quote-reference-invalid">无效引用</span>
)}
</NodeViewWrapper>
)
}

export function insertAgentQuoteReferenceAtSelection(view: EditorView, referencePayload: string): boolean {
const nodeType = view.state.schema.nodes.agentQuoteReference
if (!nodeType) return false

const transaction = view.state.tr.replaceSelectionWith(nodeType.create({ reference: referencePayload }))
const cursorPosition = transaction.selection.from
// A trailing space supplies a real text position after an inline atom. Without it,
// Chromium may move the caret into a following paragraph when the quote is at line end.
transaction.insertText(' ', cursorPosition)
transaction.setSelection(TextSelection.create(transaction.doc, cursorPosition + 1))
view.dispatch(transaction.scrollIntoView())
return true
}

export const AgentQuoteReferenceExtension = Node.create({
name: 'agentQuoteReference',
group: 'inline',
inline: true,
atom: true,
selectable: true,

addAttributes() {
return {
reference: {
default: '',
parseHTML: (element: HTMLElement) => element.getAttribute('data-reference') ?? '',
renderHTML: (attributes: Record<string, string>) => (
attributes.reference ? { 'data-reference': attributes.reference } : {}
),
},
}
},

parseHTML() {
return [{ tag: 'span[data-type="agent-quote-reference"]' }]
},

renderHTML({ HTMLAttributes }) {
const reference = parseAgentQuoteReferencePayload(String(HTMLAttributes['data-reference'] ?? ''))
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-type': 'agent-quote-reference',
class: 'agent-quote-reference-fallback',
}),
reference ? getAgentQuoteReferenceLabel(reference) : '无效引用',
]
},

addNodeView() {
return ReactNodeViewRenderer(AgentQuoteReferenceNodeView)
},
})
Loading