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
104 changes: 104 additions & 0 deletions background/ports/aiStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { streamText } from "@xsai/stream-text"

import { getAiConfig } from "~utils/ai-service"
import { debugLog } from "~utils/logger"

export interface AiStreamRequest {
content: string
processedPrompt: string
}

export type AiStreamResponse =
| { type: "chunk"; text: string }
| {
type: "usage"
usage: {
total_tokens?: number
prompt_tokens?: number
completion_tokens?: number
}
}
| { type: "done" }
| { type: "error"; message: string }

const handler: PlasmoMessaging.PortHandler<
AiStreamRequest,
AiStreamResponse
> = async (req, res) => {
try {
const { content, processedPrompt } = req.body

const { apiKey, baseURL, systemPrompt, model } = await getAiConfig()

if (!apiKey) {
res.send({ type: "error", message: "API key not configured" })
return
}

if (!model) {
res.send({ type: "error", message: "AI model not selected" })
return
}

debugLog("Port aiStream: starting stream", { model, baseURL })

const result = streamText({
apiKey,
baseURL,
model,
messages: [
{
role: "system",
content: systemPrompt || "你是一个有用的助手"
},
{
role: "user",
content: `${processedPrompt}\n\n内容: ${content}`
}
],
streamOptions: {
includeUsage: true
}
})

const reader = result.textStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
res.send({ type: "chunk", text: value })
}
} finally {
reader.releaseLock()
}

// Send usage after stream completes
try {
const usage = await result.usage
if (usage) {
res.send({
type: "usage",
usage: {
total_tokens: usage.total_tokens,
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens
}
})
}
} catch (usageError) {
debugLog("Port aiStream: failed to get usage", usageError)
}

res.send({ type: "done" })
debugLog("Port aiStream: stream completed")
} catch (error) {
debugLog("Port aiStream: error", error)
res.send({
type: "error",
message: (error as Error).message || "Unknown error"
})
}
}

export default handler
50 changes: 47 additions & 3 deletions hooks/useAiSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { generateSummary, getAiConfig } from "~utils/ai-service"
import { useI18n } from "~utils/i18n"
import { debugLog } from "~utils/logger"
import { processTemplate } from "~utils/template"
import { isContentScript } from "~utils/theme/runtime-env"

import useAiPrompt from "./useAiPrompt"
import useHistorySaver, { type UsageInfo } from "./useHistorySaver"
import usePortStream from "./usePortStream"
import useStreamProcessor from "./useStreamProcessor"

export interface UseAiSummaryResult {
Expand Down Expand Up @@ -57,6 +59,13 @@ export const useAiSummary = (
const { streamingText, usage, resetStream, processStream, setUsage } =
useStreamProcessor()

const {
streamingText: portStreamingText,
usage: portUsage,
startStream,
resetStream: resetPortStream
} = usePortStream()

const { saveToHistory } = useHistorySaver()

const generateSummaryText = useCallback(async () => {
Expand Down Expand Up @@ -87,6 +96,7 @@ export const useAiSummary = (
setIsLoading(true)
setResult(null)
resetStream()
resetPortStream()

// Process template variables in prompt
let processedPrompt = customPrompt
Expand All @@ -95,7 +105,33 @@ export const useAiSummary = (
}
debugLog("processedPrompt", processedPrompt)

if (isMobile) {
if (isContentScript()) {
// Content script: relay through background port to avoid CORS
debugLog("Content script detected, using port relay")

const { text, usage: relayUsage } = await startStream(
content,
processedPrompt
)

fullText = text
setSummary(fullText)
onSummaryGenerated?.(fullText)

if (relayUsage) {
setUsage(relayUsage)
currentUsage = relayUsage
}

await saveToHistory({
text: fullText,
customPrompt,
scrapedData,
usage: currentUsage
})
savedToHistory = true
setError(null)
} else if (isMobile) {
// Mobile: use non-streaming generation
debugLog("Mobile device detected, using direct generation")

Expand Down Expand Up @@ -213,23 +249,31 @@ export const useAiSummary = (
usage,
saveToHistory,
resetStream,
resetPortStream,
startStream,
processStream,
setUsage,
t
])

// In content script context, use port streaming text; otherwise use direct streaming text
const activeStreamingText = isContentScript()
? portStreamingText
: streamingText
const activeUsage = isContentScript() ? portUsage : usage

return {
result,
summary,
streamingText,
streamingText: activeStreamingText,
isLoading,
error,
customPrompt,
setCustomPrompt,
systemPrompt,
generateSummaryText,
saveAsDefaultPrompt,
usage,
usage: activeUsage,
modelId
}
}
119 changes: 119 additions & 0 deletions hooks/usePortStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { usePort } from "@plasmohq/messaging/hook"
import { useCallback, useEffect, useRef, useState } from "react"

import type {
AiStreamRequest,
AiStreamResponse
} from "~background/ports/aiStream"
import { debugLog } from "~utils/logger"

import type { UsageInfo } from "./useHistorySaver"

interface PortStreamResult {
text: string
usage: UsageInfo | null
}

/**
* Hook for streaming AI text via background port messaging.
* Used in content script context to bypass CORS restrictions.
*
* Always call this hook (hooks can't be conditional),
* but only invoke `startStream` when in content script context.
*/
export function usePortStream() {
const [streamingText, setStreamingText] = useState("")
const [usage, setUsage] = useState<UsageInfo | null>(null)

const port = usePort<AiStreamRequest, AiStreamResponse>("aiStream")

// Use refs to resolve/reject the stream promise from the listener
const resolveRef = useRef<((result: PortStreamResult) => void) | null>(null)
const rejectRef = useRef<((error: Error) => void) | null>(null)
const accumulatedTextRef = useRef("")
const usageRef = useRef<UsageInfo | null>(null)

// Set up the port listener once
useEffect(() => {
const { port: chromePort, disconnect } = port.listen<AiStreamResponse>(
(msg) => {
switch (msg.type) {
case "chunk":
accumulatedTextRef.current += msg.text
setStreamingText((prev) => prev + msg.text)
break

case "usage":
usageRef.current = msg.usage as UsageInfo
setUsage(msg.usage as UsageInfo)
break

case "done":
debugLog(
"Port stream completed, text length:",
accumulatedTextRef.current.length
)
resolveRef.current?.({
text: accumulatedTextRef.current,
usage: usageRef.current
})
resolveRef.current = null
rejectRef.current = null
break

case "error":
debugLog("Port stream error:", msg.message)
rejectRef.current?.(new Error(msg.message))
resolveRef.current = null
rejectRef.current = null
break
}
}
)

// Reject pending promise if the port disconnects (e.g. service worker restart)
const onDisconnect = (port: chrome.runtime.Port) => {
if (resolveRef.current) {
debugLog("Port disconnected while stream in progress")
rejectRef.current?.(new Error("Port disconnected", { cause: port }))
resolveRef.current = null
rejectRef.current = null
}
}
chromePort.onDisconnect.addListener(onDisconnect)

return () => {
disconnect()
chromePort.onDisconnect.removeListener(onDisconnect)
}
}, [port])

const resetStream = useCallback(() => {
setStreamingText("")
setUsage(null)
accumulatedTextRef.current = ""
usageRef.current = null
}, [])

const startStream = useCallback(
(content: string, processedPrompt: string): Promise<PortStreamResult> => {
resetStream()

return new Promise<PortStreamResult>((resolve, reject) => {
resolveRef.current = resolve
rejectRef.current = reject
port.send({ content, processedPrompt })
})
},
[port, resetStream]
)

return {
streamingText,
usage,
startStream,
resetStream
}
}

export default usePortStream