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
11 changes: 11 additions & 0 deletions .changeset/lan-remote-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/vis-server": patch
---

feat(vis): show LAN URLs when binding to 0.0.0.0 for remote control

When vis-server binds to 0.0.0.0 or :: (all interfaces), the startup
banner and CLI output now display the actual LAN IP addresses that
other devices on the same network can use to connect. This enables
lan-range remote control from phones, tablets, or other machines.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/cli/sub/vis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly lanUrls?: string[];
readonly close: () => Promise<void>;
}

Expand Down Expand Up @@ -86,6 +87,12 @@ export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void>
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;

deps.stdout.write(`kimi vis is running at ${server.url}\n`);
if (server.lanUrls !== undefined && server.lanUrls.length > 0) {
deps.stdout.write(`LAN access:\n`);
for (const lanUrl of server.lanUrls) {
deps.stdout.write(` ${lanUrl}\n`);
}
}
deps.stdout.write('Press Ctrl-C to stop.\n');

if (opts.open) {
Expand Down
17 changes: 17 additions & 0 deletions apps/vis/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import os from 'node:os';

export function isAllInterfaces(host: string): boolean {
return host === '0.0.0.0' || host === '::';
}

export function getLocalNetworkAddresses(port: number): string[] {
const addresses: string[] = [];
for (const [, ifaceList] of Object.entries(os.networkInterfaces())) {
for (const iface of ifaceList ?? []) {
if (!iface.internal && iface.family === 'IPv4') {
addresses.push(`http://${iface.address}:${port}/`);
}
}
}
return addresses;
}

/** Resolve KIMI_CODE_HOME (env > ~/.kimi-code). */
export function resolveKimiCodeHome(): string {
Expand Down
4 changes: 2 additions & 2 deletions apps/vis/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { formatStartupBanner } from './startup-banner';
async function main(): Promise<void> {
const host = resolveHost();
const authToken = resolveVisAuthToken(host);
const { port } = await startVisServer({ host, authToken });
const { port, lanUrls } = await startVisServer({ host, authToken });
process.stdout.write(
formatStartupBanner({ authToken, host, kimiCodeHome: KIMI_CODE_HOME, port }),
formatStartupBanner({ authToken, host, kimiCodeHome: KIMI_CODE_HOME, port, lanUrls }),
);
}

Expand Down
4 changes: 3 additions & 1 deletion apps/vis/server/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { serve } from '@hono/node-server';

import { createApp } from './app';
import { hostForUrl, resolveHost, resolveKimiCodeHome, resolvePort, resolveVisAuthToken } from './config';
import { getLocalNetworkAddresses, hostForUrl, isAllInterfaces, resolveHost, resolveKimiCodeHome, resolvePort, resolveVisAuthToken } from './config';
import type { WebAsset } from './lib/web-asset';

export interface StartVisServerOptions {
Expand All @@ -18,6 +18,7 @@ export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly lanUrls?: string[];
readonly close: () => Promise<void>;
}

Expand All @@ -36,6 +37,7 @@ export async function startVisServer(
port: info.port,
host,
url: `http://${hostForUrl(host)}:${info.port}/`,
lanUrls: isAllInterfaces(host) ? getLocalNetworkAddresses(info.port) : undefined,
close: () =>
new Promise<void>((done, fail) => {
server.close((err?: Error) => (err ? fail(err) : done()));
Expand Down
13 changes: 10 additions & 3 deletions apps/vis/server/src/startup-banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ export interface StartupBannerOptions {
readonly host: string;
readonly kimiCodeHome: string;
readonly port: number;
readonly lanUrls?: string[];
}

export function formatStartupBanner(options: StartupBannerOptions): string {
const authStatus = options.authToken === undefined ? 'auth=disabled' : 'auth=required';
return (
let banner =
`[vis-server] listening on http://${hostForUrl(options.host)}:${String(options.port)} ` +
`(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n`
);
`(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n`;
if (options.lanUrls !== undefined && options.lanUrls.length > 0) {
banner +=
`[vis-server] LAN access:\n` +
options.lanUrls.map((url) => ` - ${url}`).join('\n') +
'\n';
}
return banner;
}
38 changes: 37 additions & 1 deletion apps/vis/server/test/lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { hostForUrl } from '../../src/config';
import { hostForUrl, isAllInterfaces, getLocalNetworkAddresses } from '../../src/config';

describe('hostForUrl', () => {
it('brackets a bare IPv6 literal for use in a URL', () => {
Expand All @@ -18,3 +18,39 @@ describe('hostForUrl', () => {
expect(hostForUrl('[::1]')).toBe('[::1]');
});
});

describe('isAllInterfaces', () => {
it('returns true for 0.0.0.0', () => {
expect(isAllInterfaces('0.0.0.0')).toBe(true);
});

it('returns true for ::', () => {
expect(isAllInterfaces('::')).toBe(true);
});

it('returns false for loopback hosts', () => {
expect(isAllInterfaces('127.0.0.1')).toBe(false);
expect(isAllInterfaces('localhost')).toBe(false);
expect(isAllInterfaces('::1')).toBe(false);
});
});

describe('getLocalNetworkAddresses', () => {
it('returns non-empty array of IPv4 URLs for a valid port', () => {
const addresses = getLocalNetworkAddresses(3001);
expect(addresses.length).toBeGreaterThan(0);
for (const addr of addresses) {
expect(addr).toMatch(/^http:\/\/\d+\.\d+\.\d+\.\d+:3001\/$/);
}
});

it('returns different URLs for different ports', () => {
const addrs3001 = getLocalNetworkAddresses(3001);
const addrs8080 = getLocalNetworkAddresses(8080);
expect(addrs3001.length).toBe(addrs8080.length);
for (let i = 0; i < addrs3001.length; i++) {
expect(addrs3001[i]).toContain(':3001/');
expect(addrs8080[i]).toContain(':8080/');
}
});
});
26 changes: 25 additions & 1 deletion packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { parseHTML as rawParseHTML } from 'linkedom';
import { Agent, type Dispatcher } from 'undici';

import { isProxyConfigured, makeNoProxyMatcher, resolveNoProxy } from '#/_base/utils/proxy';
import {
MODEL_ACCEPTED_IMAGE_MIMES,
normalizeImageMime,
} from '#/agent/media/image-format-policy';

import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types';

Expand Down Expand Up @@ -109,6 +113,27 @@ export class LocalFetchURLProvider implements UrlFetcher {
}
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

// Handle accepted image formats directly — convert to base64 data URL
// so the model can view them inline.
const normalizedMime = normalizeImageMime(contentType);
if (MODEL_ACCEPTED_IMAGE_MIMES.has(normalizedMime)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sniff fetched image bytes before embedding them

When an upstream response labels unsupported or non-image bytes as an accepted Content-Type such as image/png, this header-only check accepts it and builds a data:${normalizedMime} image result. Existing media policy sniffs the effective MIME because providers decode bytes and reject formats such as AVIF/HEIC even when mislabeled, so this can persist a bad image part in session history; buffer the body, sniff/canonicalize the MIME, and refuse unsupported bytes before returning kind: 'image' here and in the legacy provider.

Useful? React with 👍 / 👎.

const buffer = await response.arrayBuffer();
if (buffer.byteLength > this.maxBytes) {
throw new Error(
`Response body too large: ${String(buffer.byteLength)} bytes exceeds maxBytes (${String(this.maxBytes)}).`,
);
}
const base64 = Buffer.from(buffer).toString('base64');
const dataUrl = `data:${normalizedMime};base64,${base64}`;
return {
content: `Fetched image (${normalizedMime}).`,
kind: 'image',
imageUrl: dataUrl,
};
}

const body = await response.text();

const actualBytes = Buffer.byteLength(body, 'utf8');
Expand All @@ -118,7 +143,6 @@ export class LocalFetchURLProvider implements UrlFetcher {
);
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType.startsWith('text/plain') || contentType.startsWith('text/markdown')) {
return { content: body, kind: 'passthrough' };
}
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
* - `extracted` — the body was an HTML page; only the main article text
* was extracted and returned.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export type UrlFetchKind = 'passthrough' | 'extracted' | 'image';

export interface UrlFetchResult {
readonly content: string;
readonly kind: UrlFetchKind;
/** When the fetched URL is an image, the image data as a base64 data URL. */
readonly imageUrl?: string;
}

export interface UrlFetcher {
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core-v2/src/app/web/tools/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,19 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {
{ toolCallId, signal }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId, signal });
const { content, kind, imageUrl } = await this.fetcher.fetch(args.url, { toolCallId, signal });

// When the fetcher returns an image, emit it as an image_url content part
// so the model can see it directly.
if (kind === 'image' && imageUrl !== undefined) {
return {
isError: false,
output: [
{ type: 'text', text: content },
{ type: 'image_url', image_url: { url: imageUrl } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Emit image parts with the repo's camelCase field

When FetchURL returns an image, this constructs a content part with image_url, but the repository ContentPart shape uses imageUrl, and the existing media tools/provider encoders read part.imageUrl.url. With this shape, fetched images will not serialize as valid image parts for the next model request; the legacy copy in packages/agent-core/src/tools/builtin/web/fetch-url.ts needs the same fix.

Useful? React with 👍 / 👎.

],
};
}

if (!content) {
return {
Expand Down
31 changes: 21 additions & 10 deletions packages/agent-core-v2/test/app/web/tools/fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,32 +90,43 @@ describe('FetchURLTool abort signal', () => {
});

describe('FetchURLTool output note', () => {
async function runKind(kind: UrlFetchResult['kind']): Promise<string> {
async function runKind(kind: UrlFetchResult['kind'], imageUrl?: string): Promise<ExecutableToolResult> {
const fetch = vi
.fn<UrlFetcher['fetch']>()
.mockResolvedValue({ content: 'BODY', kind } satisfies UrlFetchResult);
.mockResolvedValue({ content: 'BODY', kind, imageUrl } satisfies UrlFetchResult);
const tool = new FetchURLTool({ fetch });
const result = await execute(tool, 'https://example.com', new AbortController().signal);
expect(result.isError).toBe(false);
if (typeof result.output !== 'string') throw new Error('expected string output');
return result.output;
return execute(tool, 'https://example.com', new AbortController().signal);
}

it('puts the passthrough note and citation reminder at the front of output', async () => {
const output = await runKind('passthrough');
expect(output).toBe(
const result = await runKind('passthrough');
expect(result.isError).toBe(false);
if (typeof result.output !== 'string') throw new Error('expected string output');
expect(result.output).toBe(
'The returned content is the full response body, returned verbatim. ' +
'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).\n\nBODY',
);
});

it('puts the extracted note and citation reminder at the front of output', async () => {
const output = await runKind('extracted');
expect(output).toBe(
const result = await runKind('extracted');
expect(result.isError).toBe(false);
if (typeof result.output !== 'string') throw new Error('expected string output');
expect(result.output).toBe(
'The returned content is the main text extracted from the page. ' +
'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).\n\nBODY',
);
});

it('returns image as ContentPart[] when kind is image', async () => {
const result = await runKind('image', 'data:image/png;base64,abc123');
expect(result.isError).toBe(false);
if (typeof result.output === 'string') throw new Error('expected ContentPart[] output');
expect(result.output).toEqual([
{ type: 'text', text: 'BODY' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,abc123' } },
]);
});
});

describe('LocalFetchURLProvider abort signal', () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/agent-core/src/tools/builtin/web/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
* - `extracted` — the body was an HTML page; only the main article text
* was extracted and returned.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export type UrlFetchKind = 'passthrough' | 'extracted' | 'image';

export interface UrlFetchResult {
/** The text handed to the LLM. */
/** The text handed to the LLM, or a description when the result is an image. */
content: string;
/** Whether `content` is a verbatim passthrough or extracted main text. */
/** Whether `content` is a verbatim passthrough, extracted main text, or an image. */
kind: UrlFetchKind;
/** When the fetched URL is an image, the image data as a base64 data URL. */
imageUrl?: string;
}

export interface UrlFetcher {
Expand Down Expand Up @@ -89,7 +91,19 @@
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId });
const { content, kind, imageUrl } = await this.fetcher.fetch(args.url, { toolCallId });

// When the fetcher returns an image, emit it as an image_url content part
// so the model can see it directly.
if (kind === 'image' && imageUrl !== undefined) {
return {
isError: false,
output: [
{ type: 'text', text: content },
{ type: 'image_url', image_url: { url: imageUrl } },

Check failure on line 103 in packages/agent-core/src/tools/builtin/web/fetch-url.ts

View workflow job for this annotation

GitHub Actions / typecheck

Object literal may only specify known properties, but 'image_url' does not exist in type 'ImageURLPart'. Did you mean to write 'imageUrl'?
],
};
}

if (!content) {
return {
Expand Down
26 changes: 25 additions & 1 deletion packages/agent-core/src/tools/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import { Agent, type Dispatcher } from 'undici';

import { isProxyConfigured, makeNoProxyMatcher, resolveNoProxy } from '../../utils/proxy';
import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../builtin';
import {
MODEL_ACCEPTED_IMAGE_MIMES,
normalizeImageMime,
} from '../../tools/support/image-format-policy';

// Readability's .d.ts references the global `Document` type, but this
// package compiles with `lib: ES2023` (no DOM). Extracting the
Expand Down Expand Up @@ -253,6 +257,27 @@ export class LocalFetchURLProvider implements UrlFetcher {
}
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

// Handle accepted image formats directly — convert to base64 data URL
// so the model can view them inline.
const normalizedMime = normalizeImageMime(contentType);
if (MODEL_ACCEPTED_IMAGE_MIMES.has(normalizedMime)) {
const buffer = await response.arrayBuffer();
if (buffer.byteLength > this.maxBytes) {
throw new Error(
`Response body too large: ${String(buffer.byteLength)} bytes exceeds maxBytes (${String(this.maxBytes)}).`,
);
}
const base64 = Buffer.from(buffer).toString('base64');
const dataUrl = `data:${normalizedMime};base64,${base64}`;
return {
content: `Fetched image (${normalizedMime}).`,
kind: 'image',
imageUrl: dataUrl,
};
}

const body = await response.text();

// Servers may omit content-length — measure again defensively.
Expand All @@ -263,7 +288,6 @@ export class LocalFetchURLProvider implements UrlFetcher {
);
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType.startsWith('text/plain') || contentType.startsWith('text/markdown')) {
return { content: body, kind: 'passthrough' };
}
Expand Down
Loading
Loading