Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 files changed
+37694 -29428

No files matched your search

+38 -169
View File
@@ -1,203 +1,72 @@
/**
* `dsh web` — the web-shape assembly: startHost + dist resolution +
* startWebServer + the URL line + signal wiring. Mixing host and carrier
* concerns is this app module's job (packages stay single-sided).
* `dsh web` — thin bin over the config-tree boot: parse argv, run
* AppCLIEntry, print the URL line, wire signals. All composition lives in
* cordis.yml; all boot glue lives in AppCLIEntry.
*/
import { parseArgs } from 'node:util'
import { networkInterfaces } from 'node:os'
import { createRequire } from 'node:module'
import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
// --- Client composition (composition decisions live in the composing app) ---
// The composition layer owns one decision: which plugin packages mount (the
// roster). Dependency edges and the boot prefetch tier live in each package's
// dshClient declaration.
/**
* Dev-only plugin: the client HMR driver. Whether it composes in is a
* deployment decision — the dev graph includes its row, the prod graph does
* not mount it at all.
*/
const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
const CLIENT_BUNDLE_POLL_MS = 500
/** The client plugin roster (flat; per-row boot behavior comes from manifests). */
const CLIENT_PACKAGES = [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
export async function runWeb(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: {
host: { type: 'string', default: LOOPBACK_HOST },
port: { type: 'string', default: '3080' },
'max-request-body-bytes': { type: 'string' },
host: { type: 'string' },
port: { type: 'string' },
dev: { type: 'boolean', default: false },
'workspace-root': { type: 'string' },
provider: { type: 'string' },
model: { type: 'string' },
dev: { type: 'boolean', default: false },
},
allowPositionals: false,
})
if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
process.stderr.write(
`dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
)
process.exit(1)
}
const hostAddress = values.host
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
const configuredMaxRequestBodyBytes = values['max-request-body-bytes'] === undefined
? undefined
: Number(values['max-request-body-bytes'])
if (configuredMaxRequestBodyBytes !== undefined
&& (!Number.isInteger(configuredMaxRequestBodyBytes)
|| configuredMaxRequestBodyBytes < 1)) {
process.stderr.write(
`dsh web: invalid --max-request-body-bytes ${values['max-request-body-bytes']}\n`,
)
process.exit(1)
}
// Default routing: --provider deepseek (implicit) keeps the DeepSeek-only
// assembly; any other --provider additionally mounts that pi-ai catalog
// route (credentials via the provider's ambient discovery, e.g.
// ANTHROPIC_API_KEY) so visual-capable models are reachable from the shipped
// Web app. A non-default provider requires an explicit --model — this shell
// has no evidence for inventing another provider's default.
const provider = values.provider ?? 'deepseek'
if (provider !== 'deepseek' && values.model === undefined) {
process.stderr.write(`dsh web: --provider ${provider} requires an explicit --model\n`)
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught
// by design); an unknown --provider fails the pi-ai catalog check the same way.
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: { maxBytes: 65_536 },
sessionTitleLlm: true,
...provider === 'deepseek' ? {} : { piAiProviders: [{ provider }] },
...values.provider === undefined ? {} : { provider: values.provider },
...values.model === undefined ? {} : { model: values.model },
},
})
const attachments = host.ctx.get('attachments')
if (attachments === undefined) throw new Error('dsh web: attachment service unavailable')
const maxRequestBodyBytes = configuredMaxRequestBodyBytes
?? Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3)
+ REQUEST_ENVELOPE_HEADROOM_BYTES
// Client plugin chain: in-memory Loader tree over the composed roster, then
// the registry that feeds the __DSH_BOOT__ entry graph and
// /plugins/<id>/client.js. All row content comes from dshClient discovery
// over the mounted roster (dev adds the HMR driver row and turns on the
// bundle watch that drives rebuilt frames).
const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []]
const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
const webPlugins = createHostWebPluginRegistry({
ctx: host.ctx,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
})
if (values.dev) {
// Dev visibility (the registry is a library and never prints): list what
// the bundle watch covers, then log every observed rebuild. This is a
// second onRebuilt subscription — the SSE relay inside the webserver is
// unaffected (multicast).
const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
const bundlePaths = [...revs.keys()]
.map(id => webPlugins.clientPath(id))
.filter((path): path is string => path !== undefined)
console.log(
`dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`,
)
webPlugins.onRebuilt((id, rev) => {
console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
revs.set(id, rev)
})
}
// Published so the webserver invariant companion can audit manifest/bundle
// consistency; nothing else reads this key.
host.ctx.reflect.provide('webPlugins', webPlugins)
// Dist location is workspace knowledge of this app: resolved through
// @deepseek-ai/dsh-frontend's package exports, not configured.
const require = createRequire(import.meta.url)
let distIndex: string
try {
distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
await host.dispose()
process.exit(1)
}
let exiting = false
async function shutdown(code: number): Promise<void> {
if (exiting) return
exiting = true
try {
await server.close()
await host.dispose()
} finally {
process.exit(code)
let port: number | undefined
if (values.port !== undefined) {
port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
}
let server: Awaited<ReturnType<typeof startWebServer>>
try {
server = await startWebServer(
{
host: hostAddress,
port,
distIndex,
apiHandler: host.handler,
maxRequestBodyBytes,
webPlugins,
},
(err: Error) => {
process.stderr.write(`dsh web: ${String(err)}\n`)
void shutdown(1)
},
)
} catch (error: unknown) {
// listen failed (EADDRINUSE…): no server to close, dispose the host directly.
process.stderr.write(`dsh web: ${String(error)}\n`)
await host.dispose()
process.exit(1)
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
dev: values.dev,
...values.host !== undefined ? { host: values.host } : {},
...port !== undefined ? { port } : {},
...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {},
...values.provider !== undefined ? { provider: values.provider } : {},
...values.model !== undefined ? { model: values.model } : {},
})
const { ctx, port: boundPort } = await entry.run()
let exiting = false
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lan = hostAddress === ALL_INTERFACES_HOST
const lanCandidate = values.host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
const localUrl = `http://${LOOPBACK_HOST}:${server.port}`
console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
process.on('SIGTERM', () => { void shutdown(0) })
process.on('SIGINT', () => { void shutdown(130) })
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
}