6 Commits
Author SHA1 Message Date
lofyer 1329251b5a chore: release 0.9.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / linux arm64 (push) Blocked by required conditions
Cross-platform packages / macos arm64 (push) Blocked by required conditions
Cross-platform packages / windows arm64 (push) Blocked by required conditions
Cross-platform packages / linux x64 (push) Blocked by required conditions
Cross-platform packages / macos x64 (push) Blocked by required conditions
Cross-platform packages / windows x64 (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
2026-08-14 18:28:22 +08:00
lofyer a5f1e31900 feat: preserve chat reading position 2026-08-14 18:27:55 +08:00
lofyer 57c57d232c chore: remove redundant Harness compatibility notice 2026-08-14 16:38:45 +08:00
lofyer a83f24a334 docs: clarify failed release recovery 2026-08-14 16:04:37 +08:00
lofyer 81f7e4f9e5 chore: release 0.9.1
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-14 14:42:49 +08:00
lofyer d070091350 fix: stage target runtime dependencies for packaging 2026-08-14 14:42:31 +08:00
15 changed files with 635 additions and 23 deletions
+17
View File
@@ -141,6 +141,23 @@ not require release notes.
displays the release notes matching the current interface language and displays the release notes matching the current interface language and
contains no button linking to a full release page. contains no button linking to a full release page.
When recovering from a version tag whose workflow never published a GitHub
Release and its assets:
- If the approved source and release metadata do not need to change, rerun the
failed jobs for the same immutable tag instead of creating another tag.
- If a code or metadata change requires a higher version and a new tag, carry
the failed candidate's approved user-facing notes forward into the recovery
version, then remove the superseded failed version's entry from
`resources/release-notes.json`.
- The packaged first-open modal must show that carried-forward content only
once under the recovery version. Never retain both the failed version and
its cumulative recovery copy, because users upgrading across them would see
duplicate content.
- Never remove the packaged history for a version that successfully published
a GitHub Release. Verify the failed release state before treating an entry as
superseded.
Never create or push a release tag, and never push a previously created Never create or push a release tag, and never push a previously created
release tag, before the release-note draft has received explicit approval. release tag, before the release-note draft has received explicit approval.
+259
View File
@@ -1,9 +1,12 @@
const { spawn } = require('node:child_process') const { spawn } = require('node:child_process')
const { createHash } = require('node:crypto')
const { const {
createReadStream, createReadStream,
createWriteStream, createWriteStream,
existsSync, existsSync,
closeSync, closeSync,
mkdirSync,
mkdtempSync,
openSync, openSync,
readFileSync, readFileSync,
readSync, readSync,
@@ -14,6 +17,7 @@ const {
writeFileSync writeFileSync
} = require('node:fs') } = require('node:fs')
const { once } = require('node:events') const { once } = require('node:events')
const { tmpdir } = require('node:os')
const { const {
basename, basename,
dirname, dirname,
@@ -36,6 +40,9 @@ const root = join(__dirname, '..')
const packageJson = JSON.parse( const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8') readFileSync(join(root, 'package.json'), 'utf8')
) )
const packageLock = JSON.parse(
readFileSync(join(root, 'package-lock.json'), 'utf8')
)
const productName = packageJson.build?.productName ?? packageJson.name const productName = packageJson.build?.productName ?? packageJson.name
const releaseRoot = join(root, 'dist', 'release') const releaseRoot = join(root, 'dist', 'release')
const manifestName = 'release-manifest.json' const manifestName = 'release-manifest.json'
@@ -205,6 +212,29 @@ function npmInvocation(environment = process.env) {
prefixArgs: [environment.npm_execpath] prefixArgs: [environment.npm_execpath]
} }
} }
const npmCli = [
join(
dirname(process.execPath),
'node_modules',
'npm',
'bin',
'npm-cli.js'
),
join(
dirname(dirname(process.execPath)),
'lib',
'node_modules',
'npm',
'bin',
'npm-cli.js'
)
].find((candidate) => existsSync(candidate))
if (npmCli) {
return {
command: process.execPath,
prefixArgs: [npmCli]
}
}
return { return {
command: process.platform === 'win32' ? 'npm.cmd' : 'npm', command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
prefixArgs: [] prefixArgs: []
@@ -246,6 +276,38 @@ function run(command, args, environment = process.env) {
}) })
} }
function runCapture(command, args, environment = process.env) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, {
cwd: root,
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout = `${stdout}${chunk}`.slice(-1024 * 1024)
})
child.stderr.on('data', (chunk) => {
stderr = `${stderr}${chunk}`.slice(-64 * 1024)
})
child.once('error', rejectRun)
child.once('close', (code) => {
if (code === 0) {
resolveRun(stdout)
return
}
const error = new Error(
`命令执行失败(code ${code ?? 1}):${command} ${args.join(' ')}`
)
error.outputTail = stderr
rejectRun(error)
})
})
}
function buildElectronBuilderArguments(options, outputDirectory) { function buildElectronBuilderArguments(options, outputDirectory) {
const definition = platformDefinitions[options.platform] const definition = platformDefinitions[options.platform]
const builderFormats = [...new Set( const builderFormats = [...new Set(
@@ -443,6 +505,195 @@ function targetHarnessPaths(options) {
} }
} }
function targetRuntimePackageNames(options) {
const target = targetHarnessPaths(options)
return [
target.koffiPackage,
...(target.landlockPackage ? [target.landlockPackage] : [])
]
}
function lockedTargetRuntimePackage(packageName) {
const expectedVersion =
packageJson.optionalDependencies?.[packageName]
const lockEntry =
packageLock.packages?.[`node_modules/${packageName}`]
if (
typeof expectedVersion !== 'string' ||
lockEntry?.version !== expectedVersion ||
typeof lockEntry.resolved !== 'string' ||
typeof lockEntry.integrity !== 'string'
) {
throw new Error(
`目标 Runtime 依赖未完整锁定:${packageName}`
)
}
return {
name: packageName,
version: expectedVersion,
resolved: lockEntry.resolved,
integrity: lockEntry.integrity
}
}
function parsePackedPackageMetadata(output, expected) {
let entries
try {
entries = JSON.parse(output)
} catch (error) {
throw new Error(
`目标 Runtime 依赖 npm pack 输出无效:${expected.name}`,
{ cause: error }
)
}
const metadata =
Array.isArray(entries) && entries.length === 1
? entries[0]
: undefined
if (
metadata?.name !== expected.name ||
metadata.version !== expected.version ||
metadata.integrity !== expected.integrity ||
typeof metadata.filename !== 'string' ||
basename(metadata.filename) !== metadata.filename
) {
throw new Error(
`目标 Runtime 依赖 npm pack 元数据不匹配:${expected.name}`
)
}
return metadata
}
function verifyArchiveIntegrity(filePath, expectedIntegrity) {
const match = /^(sha(?:256|384|512))-(\S+)$/u.exec(
expectedIntegrity
)
if (!match) {
throw new Error(`不支持的依赖完整性格式:${expectedIntegrity}`)
}
const actual = createHash(match[1])
.update(readFileSync(filePath))
.digest('base64')
if (actual !== match[2]) {
throw new Error(`目标 Runtime 依赖完整性校验失败:${filePath}`)
}
}
function installedPackageMatches(packageName, expectedVersion) {
const manifestPath = join(
root,
'node_modules',
...packageName.split('/'),
'package.json'
)
if (!existsSync(manifestPath)) {
return false
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (
manifest.name !== packageName ||
manifest.version !== expectedVersion
) {
throw new Error(
`目标 Runtime 依赖版本错误:${packageName}`
)
}
return true
}
async function stageTargetRuntimeDependencies(options) {
const missing = targetRuntimePackageNames(options)
.map(lockedTargetRuntimePackage)
.filter(
(dependency) =>
!installedPackageMatches(
dependency.name,
dependency.version
)
)
if (missing.length === 0) {
return () => undefined
}
const stagingRoot = mkdtempSync(
join(tmpdir(), 'goodbuddy-release-dependencies-')
)
const stagedDirectories = []
const cleanup = () => {
for (const directory of stagedDirectories.reverse()) {
rmSync(directory, { recursive: true, force: true })
}
rmSync(stagingRoot, { recursive: true, force: true })
}
try {
const npm = npmInvocation()
for (const [index, dependency] of missing.entries()) {
const archiveDirectory = join(
stagingRoot,
`package-${index}`
)
mkdirSync(archiveDirectory, { recursive: true })
const output = await runCapture(npm.command, [
...npm.prefixArgs,
'pack',
`${dependency.name}@${dependency.version}`,
'--ignore-scripts',
'--json',
'--pack-destination',
archiveDirectory
])
const metadata = parsePackedPackageMetadata(
output,
dependency
)
const archivePath = join(
archiveDirectory,
metadata.filename
)
verifyArchiveIntegrity(archivePath, dependency.integrity)
const destination = join(
root,
'node_modules',
...dependency.name.split('/')
)
if (existsSync(destination)) {
throw new Error(
`拒绝覆盖目标 Runtime 依赖目录:${destination}`
)
}
mkdirSync(destination, { recursive: true })
stagedDirectories.push(destination)
await run('tar', [
'-xzf',
archivePath,
'-C',
destination,
'--strip-components',
'1'
])
if (
!installedPackageMatches(
dependency.name,
dependency.version
)
) {
throw new Error(
`目标 Runtime 依赖暂存失败:${dependency.name}`
)
}
console.log(
`已暂存目标 Runtime 依赖:${dependency.name}@${dependency.version}`
)
}
return cleanup
} catch (error) {
cleanup()
throw error
}
}
function verifyHarnessPackage( function verifyHarnessPackage(
resources, resources,
options, options,
@@ -1278,6 +1529,7 @@ async function main(argv = process.argv.slice(2)) {
} }
rmSync(stagingDirectory, { recursive: true, force: true }) rmSync(stagingDirectory, { recursive: true, force: true })
let cleanupTargetDependencies = () => undefined
try { try {
if (!options.skipBuild) { if (!options.skipBuild) {
const npm = npmInvocation() const npm = npmInvocation()
@@ -1286,6 +1538,8 @@ async function main(argv = process.argv.slice(2)) {
[...npm.prefixArgs, 'run', 'build'] [...npm.prefixArgs, 'run', 'build']
) )
} }
cleanupTargetDependencies =
await stageTargetRuntimeDependencies(options)
await run( await run(
process.execPath, process.execPath,
builderArguments, builderArguments,
@@ -1322,6 +1576,7 @@ async function main(argv = process.argv.slice(2)) {
) )
} }
} finally { } finally {
cleanupTargetDependencies()
rmSync(stagingDirectory, { recursive: true, force: true }) rmSync(stagingDirectory, { recursive: true, force: true })
} }
} }
@@ -1333,9 +1588,13 @@ module.exports = {
detectBinaryArchitecture, detectBinaryArchitecture,
normalizePlatform, normalizePlatform,
parseArguments, parseArguments,
parsePackedPackageMetadata,
platformDefinitions, platformDefinitions,
replaceOutput, replaceOutput,
stageTargetRuntimeDependencies,
targetRuntimePackageNames,
verifyHarnessPackage, verifyHarnessPackage,
verifyArchiveIntegrity,
verifyUnpackedOutput, verifyUnpackedOutput,
verifyArtifacts, verifyArtifacts,
verifyArtifactSignature, verifyArtifactSignature,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.0", "version": "0.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.0", "version": "0.9.2",
"license": "0BSD", "license": "0BSD",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "0.25.1", "@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.0", "version": "0.9.2",
"private": true, "private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes", "description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy", "desktopName": "GoodBuddy",
+29 -3
View File
@@ -2,7 +2,31 @@
"formatVersion": 1, "formatVersion": 1,
"releases": [ "releases": [
{ {
"version": "0.9.0", "version": "0.9.2",
"releasedAt": "2026-08-14",
"notes": {
"zh-CN": {
"features": [
"新增长对话“到底部”浮动按钮;阅读较早消息时,流式回复会保持当前位置,只有停留在底部附近时才自动跟随最新内容,并遵循系统的减少动态效果偏好。"
],
"fixes": [
"精简 DeepSeek Harness 设置说明,移除与连接配置重复的兼容性提示。",
"修复应用内 0.9.0 与 0.9.1 更新说明内容重复的问题。"
]
},
"en-US": {
"features": [
"Added a floating “Scroll to bottom” control for long conversations; streamed responses now preserve the readers position unless they remain near the bottom, and the control respects the system reduced-motion preference."
],
"fixes": [
"Simplified the DeepSeek Harness settings by removing a compatibility notice that duplicated the connection guidance.",
"Fixed duplicate in-app release-note content between versions 0.9.0 and 0.9.1."
]
}
}
},
{
"version": "0.9.1",
"releasedAt": "2026-08-14", "releasedAt": "2026-08-14",
"notes": { "notes": {
"zh-CN": { "zh-CN": {
@@ -14,7 +38,8 @@
"fixes": [ "fixes": [
"修复模型工具调用期间流式推理内容可能折叠或不可见的问题,并让推理区域在生成时自动跟随最新内容。", "修复模型工具调用期间流式推理内容可能折叠或不可见的问题,并让推理区域在生成时自动跟随最新内容。",
"修复从通道入口打开设置时未定位到所选企业微信、钉钉或微信页面的问题,并更正微信二维码扫码提示。", "修复从通道入口打开设置时未定位到所选企业微信、钉钉或微信页面的问题,并更正微信二维码扫码提示。",
"优化简体中文界面的系统字体、字号和行高,改善 Windows 与 macOS 上的小字号可读性和排版一致性。" "优化简体中文界面的系统字体、字号和行高,改善 Windows 与 macOS 上的小字号可读性和排版一致性。",
"修复 Windows arm64 发布构建缺少目标架构原生依赖、导致该平台安装包无法生成的问题。"
] ]
}, },
"en-US": { "en-US": {
@@ -26,7 +51,8 @@
"fixes": [ "fixes": [
"Fixed streamed reasoning becoming hidden during model tool calls, and kept the reasoning panel following the latest content while generation is in progress.", "Fixed streamed reasoning becoming hidden during model tool calls, and kept the reasoning panel following the latest content while generation is in progress.",
"Fixed channel shortcuts opening the wrong settings page for WeCom, DingTalk, or WeChat, and corrected the WeChat QR-code scan guidance.", "Fixed channel shortcuts opening the wrong settings page for WeCom, DingTalk, or WeChat, and corrected the WeChat QR-code scan guidance.",
"Improved Simplified Chinese typography with platform-native UI fonts, refined sizes, and line heights for clearer, more consistent text on Windows and macOS." "Improved Simplified Chinese typography with platform-native UI fonts, refined sizes, and line heights for clearer, more consistent text on Windows and macOS.",
"Fixed missing target-architecture native dependencies in Windows arm64 release builds, which prevented installers for that platform from being produced."
] ]
} }
} }
+109
View File
@@ -1168,6 +1168,115 @@ describe('App', () => {
expect(await screen.findByRole('status')).toBeVisible() expect(await screen.findByRole('status')).toBeVisible()
}) })
it('offers a floating control when more messages remain below', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000401',
projectId,
title: '长会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000402',
role: 'assistant',
content: '较早的会话内容',
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
}
])
const { container } = render(<App />)
expect(await screen.findByText('较早的会话内容')).toBeInTheDocument()
const chat = container.querySelector<HTMLElement>('.chat')
if (!chat) {
throw new Error('Missing chat scroll container')
}
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1_200 },
scrollTop: { configurable: true, writable: true, value: 100 }
})
const scrollTo = vi.fn()
chat.scrollTo = scrollTo
fireEvent.scroll(chat)
const scrollButton = screen.getByRole('button', {
name: '到底部'
})
expect(scrollButton).toHaveAttribute(
'aria-controls',
'chat-message-list'
)
expect(scrollButton).toHaveAttribute('title', '到底部')
expect(scrollButton).toHaveTextContent('')
fireEvent.click(scrollButton)
expect(scrollTo).toHaveBeenLastCalledWith({
top: 1_200,
behavior: 'smooth'
})
chat.scrollTop = 750
fireEvent.scroll(chat)
expect(
screen.queryByRole('button', { name: '到底部' })
).not.toBeInTheDocument()
})
it('keeps the reader position while a response continues below', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '继续生成较长回复' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
await act(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve())
)
)
const chat = document.querySelector<HTMLElement>('.chat')
if (!chat) {
throw new Error('Missing chat scroll container')
}
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1_200 },
scrollTop: { configurable: true, writable: true, value: 100 }
})
const scrollTo = vi.fn()
chat.scrollTo = scrollTo
fireEvent.scroll(chat)
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '新增的回复内容'
})
})
expect(await screen.findByText('新增的回复内容')).toBeInTheDocument()
await act(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve())
)
)
expect(scrollTo).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: '到底部' })
).toBeInTheDocument()
})
it('requires an accessible confirmation before permanently deleting a conversation', async () => { it('requires an accessible confirmation before permanently deleting a conversation', async () => {
render(<App />) render(<App />)
const menuTrigger = screen.getByLabelText( const menuTrigger = screen.getByLabelText(
+77 -5
View File
@@ -1,4 +1,5 @@
import { import {
ArrowDown,
Bot, Bot,
Check, Check,
CheckCircle2, CheckCircle2,
@@ -525,6 +526,8 @@ function groupMessageBlocks(
return items return items
} }
const chatBottomProximity = 96
function MessageReasoning({ function MessageReasoning({
content, content,
streaming streaming
@@ -1593,12 +1596,42 @@ function App(): React.JSX.Element {
const hydratingArtifactIds = useRef(new Set<string>()) const hydratingArtifactIds = useRef(new Set<string>())
const knowledgeScopeInitialized = useRef(false) const knowledgeScopeInitialized = useRef(false)
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLElement>(null)
const chatPinnedToBottomRef = useRef(true)
const chatScrollContextRef = useRef(`${view}:${activeId}`)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null) const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef( const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>() new Map<string, HTMLButtonElement>()
) )
const updateChatScrollPosition = useCallback((): void => {
const scrollContainer = scrollRef.current
if (!scrollContainer) {
return
}
const distanceFromBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight
const atBottom = distanceFromBottom <= chatBottomProximity
chatPinnedToBottomRef.current = atBottom
setShowScrollToBottom(!atBottom)
}, [])
const scrollChatToBottom = useCallback((): void => {
const scrollContainer = scrollRef.current
if (!scrollContainer) {
return
}
chatPinnedToBottomRef.current = true
const reduceMotion =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: reduceMotion ? 'auto' : 'smooth'
})
}, [])
const closeNarrowSidebar = useCallback((): void => { const closeNarrowSidebar = useCallback((): void => {
setSidebarOpen(false) setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus()) requestAnimationFrame(() => sidebarToggleRef.current?.focus())
@@ -3572,13 +3605,32 @@ function App(): React.JSX.Element {
useEffect(() => { useEffect(() => {
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ const scrollContext = `${view}:${activeId}`
top: scrollRef.current.scrollHeight, if (chatScrollContextRef.current !== scrollContext) {
chatScrollContextRef.current = scrollContext
chatPinnedToBottomRef.current = true
}
const scrollContainer = scrollRef.current
if (!scrollContainer) {
return
}
if (chatPinnedToBottomRef.current) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'auto' behavior: 'auto'
}) })
setShowScrollToBottom(false)
return
}
updateChatScrollPosition()
}) })
return () => cancelAnimationFrame(frame) return () => cancelAnimationFrame(frame)
}, [activeConversation?.messages]) }, [
activeConversation?.messages,
activeId,
updateChatScrollPosition,
view
])
const selectProject = (projectId: string): void => { const selectProject = (projectId: string): void => {
const project = projects.find((candidate) => candidate.id === projectId) const project = projects.find((candidate) => candidate.id === projectId)
@@ -4096,6 +4148,7 @@ function App(): React.JSX.Element {
runtime.capability === 'image-generation' ? '' : selectedExpertId runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode const workModeSnapshot = effectiveWorkMode
preparingConversations.current.add(conversationId) preparingConversations.current.add(conversationId)
chatPinnedToBottomRef.current = true
setInput('') setInput('')
updateAttachments([]) updateAttachments([])
const userMessage: Message = { const userMessage: Message = {
@@ -5266,7 +5319,13 @@ function App(): React.JSX.Element {
{view === 'chat' ? ( {view === 'chat' ? (
<PageShell variant="reading"> <PageShell variant="reading">
<section className="chat" ref={scrollRef}> <div className="chat-scroll-region">
<section
className="chat"
id="chat-message-list"
onScroll={updateChatScrollPosition}
ref={scrollRef}
>
{activeProject?.kind === 'channel' && {activeProject?.kind === 'channel' &&
!activeConversation && ( !activeConversation && (
<EmptyState <EmptyState
@@ -5876,6 +5935,19 @@ function App(): React.JSX.Element {
))} ))}
</div> </div>
</section> </section>
{showScrollToBottom && (
<button
aria-controls="chat-message-list"
aria-label={t('chat.scrollToBottom')}
className="chat-scroll-to-bottom"
onClick={scrollChatToBottom}
title={t('chat.scrollToBottom')}
type="button"
>
<ArrowDown aria-hidden="true" size={18} />
</button>
)}
</div>
<footer className="composer-wrap"> <footer className="composer-wrap">
{activeProject?.kind === 'channel' ? ( {activeProject?.kind === 'channel' ? (
-3
View File
@@ -1411,9 +1411,6 @@ describe('SettingsPanel runtime files', () => {
expect( expect(
screen.getByText('开发者预览 · OpenAI 兼容') screen.getByText('开发者预览 · OpenAI 兼容')
).toBeInTheDocument() ).toBeInTheDocument()
expect(
screen.getByText(/公网地址必须使用 HTTPS/)
).toBeInTheDocument()
const harnessOverview = screen const harnessOverview = screen
.getByText('GoodBuddy 内置 DeepSeek Harness') .getByText('GoodBuddy 内置 DeepSeek Harness')
.closest<HTMLElement>('.runtime-overview') .closest<HTMLElement>('.runtime-overview')
-3
View File
@@ -1972,9 +1972,6 @@ export function SettingsPanel({
</small> </small>
</div> </div>
</div> </div>
<p className="settings-notice">
{t('runtime.deepseekHarness.compatibilityNotice')}
</p>
<RuntimeOverviewCard <RuntimeOverviewCard
detection={detection?.deepseekHarness} detection={detection?.deepseekHarness}
detectionLabel="DeepSeek Harness" detectionLabel="DeepSeek Harness"
@@ -226,6 +226,7 @@ export const app = {
} }
}, },
retry: 'Edit and send again', retry: 'Edit and send again',
scrollToBottom: 'Scroll to bottom',
status: { status: {
responseTruncated: 'The response was too long and was truncated locally', responseTruncated: 'The response was too long and was truncated locally',
savingImage: 'Image generated; saving the result', savingImage: 'Image generated; saving the result',
@@ -238,8 +238,6 @@ export const settings = {
selectorLabel: 'DeepSeek Harness (Preview)', selectorLabel: 'DeepSeek Harness (Preview)',
title: 'DeepSeek Harness', title: 'DeepSeek Harness',
previewDescription: 'Developer preview · OpenAI-compatible', previewDescription: 'Developer preview · OpenAI-compatible',
compatibilityNotice:
'Supports OpenAI-compatible Chat Completions connections with an API key. Public endpoints must use HTTPS; loopback endpoints may use HTTP.',
description: description:
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.', 'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.',
managedSource: managedSource:
@@ -219,6 +219,7 @@ export const app = {
} }
}, },
retry: '重新编辑并发送', retry: '重新编辑并发送',
scrollToBottom: '到底部',
status: { status: {
responseTruncated: '回答过长,已在本地截断显示', responseTruncated: '回答过长,已在本地截断显示',
savingImage: '图片已生成,正在保存结果', savingImage: '图片已生成,正在保存结果',
@@ -216,8 +216,6 @@ export const settings = {
selectorLabel: 'DeepSeek Harness(预览)', selectorLabel: 'DeepSeek Harness(预览)',
title: 'DeepSeek Harness', title: 'DeepSeek Harness',
previewDescription: '开发者预览 · OpenAI 兼容', previewDescription: '开发者预览 · OpenAI 兼容',
compatibilityNotice:
'支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接;公网地址必须使用 HTTPS,本机回环地址可使用 HTTP。',
description: description:
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。', '由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
managedSource: '管理员预置的 OpenAI 兼容连接', managedSource: '管理员预置的 OpenAI 兼容连接',
+42
View File
@@ -2968,7 +2968,15 @@ button > svg {
background: var(--success); background: var(--success);
} }
.chat-scroll-region {
position: relative;
min-height: 0;
overflow: hidden;
background: var(--surface-raised);
}
.chat { .chat {
height: 100%;
min-height: 0; min-height: 0;
padding: padding:
var(--page-gutter) var(--page-gutter)
@@ -2980,6 +2988,40 @@ button > svg {
scrollbar-width: thin; scrollbar-width: thin;
} }
.chat-scroll-to-bottom {
position: absolute;
z-index: 2;
bottom: var(--space-3);
left: 50%;
display: grid;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid var(--border-control);
border-radius: 50%;
background: var(--surface-raised);
box-shadow: var(--shadow-card);
color: var(--accent);
cursor: pointer;
place-items: center;
transform: translateX(-50%);
transition:
border-color var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
color var(--motion-fast) ease-out,
opacity var(--motion-fast) ease-out;
}
.chat-scroll-to-bottom:hover {
border-color: var(--accent-selected);
background: var(--accent-subtle);
}
.chat-scroll-to-bottom:active {
background: var(--accent-selected);
opacity: 0.82;
}
.welcome { .welcome {
padding: 26px 0 30px; padding: 26px 0 30px;
text-align: center; text-align: center;
+95
View File
@@ -6,6 +6,7 @@ import {
rmSync, rmSync,
writeFileSync writeFileSync
} from 'node:fs' } from 'node:fs'
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
@@ -44,11 +45,27 @@ interface ReleaseBuilderModule {
arguments_: string[], arguments_: string[],
environment?: { platform: string; arch: string } environment?: { platform: string; arch: string }
) => ReleaseOptions ) => ReleaseOptions
parsePackedPackageMetadata: (
output: string,
expected: {
name: string
version: string
integrity: string
}
) => {
name: string
version: string
integrity: string
filename: string
}
replaceOutput: ( replaceOutput: (
stagingDirectory: string, stagingDirectory: string,
destination: string, destination: string,
options: ReleaseOptions options: ReleaseOptions
) => void ) => void
targetRuntimePackageNames: (
options: ReleaseOptions
) => string[]
verifyHarnessPackage: ( verifyHarnessPackage: (
resources: string, resources: string,
options: ReleaseOptions, options: ReleaseOptions,
@@ -73,6 +90,10 @@ interface ReleaseBuilderModule {
options: ReleaseOptions options: ReleaseOptions
) => void ) => void
verifyPortableZip: (filePath: string) => void verifyPortableZip: (filePath: string) => void
verifyArchiveIntegrity: (
filePath: string,
expectedIntegrity: string
) => void
writeManifest: ( writeManifest: (
directory: string, directory: string,
options: ReleaseOptions options: ReleaseOptions
@@ -296,6 +317,80 @@ describe('release build arguments', () => {
}) })
}) })
it('identifies the native packages required by each release target', () => {
expect(
releaseBuilder.targetRuntimePackageNames({
...windowsOptions,
arch: 'arm64'
})
).toEqual(['@koromix/koffi-win32-arm64'])
expect(
releaseBuilder.targetRuntimePackageNames({
...windowsOptions,
platform: 'linux',
formats: ['AppImage', 'deb']
})
).toEqual([
'@koromix/koffi-linux-x64',
'@deepseek-ai/node-addon-landlock-run-linux-x64'
])
})
it('validates packed target dependency metadata and archive integrity', () => {
const directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-release-dependency-test-')
)
const archive = join(directory, 'target.tgz')
const contents = Buffer.from('locked target dependency')
writeFileSync(archive, contents)
const integrity = `sha512-${createHash('sha512')
.update(contents)
.digest('base64')}`
const expected = {
name: '@koromix/koffi-win32-arm64',
version: '3.1.4',
integrity
}
try {
expect(
releaseBuilder.parsePackedPackageMetadata(
JSON.stringify([
{
...expected,
filename: 'koffi-win32-arm64.tgz'
}
]),
expected
)
).toMatchObject({
...expected,
filename: 'koffi-win32-arm64.tgz'
})
releaseBuilder.verifyArchiveIntegrity(archive, integrity)
expect(() =>
releaseBuilder.verifyArchiveIntegrity(
archive,
`sha512-${Buffer.alloc(64).toString('base64')}`
)
).toThrow('完整性校验失败')
expect(() =>
releaseBuilder.parsePackedPackageMetadata(
JSON.stringify([
{
...expected,
version: '3.1.5',
filename: 'koffi-win32-arm64.tgz'
}
]),
expected
)
).toThrow('元数据不匹配')
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
it('keeps Web3D test fixtures out of release resources', () => { it('keeps Web3D test fixtures out of release resources', () => {
const packageJson = require('../package.json') as { const packageJson = require('../package.json') as {
build: { build: {