mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
8 Commits
refactor/s
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f151ca9ac1 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
@@ -1333,6 +1355,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
|
||||
@@ -20,13 +20,28 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
cmd := NewCmdApi(f, runF)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestRootCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
// Should return an error
|
||||
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -86,10 +86,13 @@ func symArrow() string {
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
SkillsLayout string
|
||||
FlatSkills string
|
||||
FlatSet bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
@@ -108,6 +111,7 @@ Detects the installation method automatically:
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.FlatSet = cmd.Flags().Changed("flat-skills")
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
@@ -115,6 +119,8 @@ Use --check to only check for updates without installing.`,
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate or hybrid")
|
||||
cmd.Flags().StringVar(&opts.FlatSkills, "flat-skills", "", "comma-separated skills kept as top-level skills when the effective layout is hybrid")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
@@ -122,6 +128,9 @@ Use --check to only check for updates without installing.`,
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
if err := validateSkillsLayoutOptions(opts); err != nil {
|
||||
return reportError(opts, io, "validation_error", err)
|
||||
}
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
@@ -147,7 +156,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -208,7 +217,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -287,7 +296,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -324,16 +333,20 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout, requestedFlat string, flatSet bool) *skillscheck.SyncResult {
|
||||
layout, flat := resolveSkillsSyncOptions(requestedLayout, requestedFlat, flatSet)
|
||||
layoutExplicit := strings.TrimSpace(requestedLayout) != ""
|
||||
if !force && !layoutExplicit && !flatSet {
|
||||
if existing, existingLayout, ok := skillscheck.ReadSyncedVersionAndLayout(); ok && existingLayout != "" && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
Version: stateVersion,
|
||||
Layout: layout,
|
||||
FlatSkills: flat,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
@@ -341,6 +354,47 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
return result
|
||||
}
|
||||
|
||||
func validateSkillsLayoutOptions(opts *UpdateOptions) errs.TypedError {
|
||||
if _, ok := skillscheck.NormalizeLayout(opts.SkillsLayout); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or hybrid").WithParam("--skills-layout")
|
||||
}
|
||||
layout, flat := resolveSkillsSyncOptions(opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
if layout != skillscheck.LayoutHybrid {
|
||||
return nil
|
||||
}
|
||||
for _, skill := range flat {
|
||||
if skill == "lark-shared" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "lark-shared cannot be selected by --flat-skills; it is managed automatically for lark-suite compatibility").WithParam("--flat-skills")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSkillsSyncOptions(requestedLayout, requestedFlat string, flatSet bool) (string, []string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil {
|
||||
readable = false
|
||||
state = nil
|
||||
}
|
||||
|
||||
layout := skillscheck.LayoutSeparate
|
||||
if strings.TrimSpace(requestedLayout) != "" {
|
||||
layout, _ = skillscheck.NormalizeLayout(requestedLayout)
|
||||
} else if readable && state != nil {
|
||||
if stateLayout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" {
|
||||
layout = stateLayout
|
||||
}
|
||||
}
|
||||
|
||||
if flatSet {
|
||||
return layout, skillscheck.ParseFlatSkills(requestedFlat)
|
||||
}
|
||||
if readable && state != nil {
|
||||
return layout, state.FlatSkills
|
||||
}
|
||||
return layout, []string{}
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
@@ -387,6 +441,12 @@ func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
if state.Layout != "" {
|
||||
status["layout"] = state.Layout
|
||||
}
|
||||
if len(state.FlatSkills) > 0 {
|
||||
status["flat_skills"] = state.FlatSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
@@ -397,6 +457,7 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
env["skills_hint"] = skillsFailureHint()
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
@@ -410,10 +471,17 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
"layout": r.Layout,
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
if len(r.Collected) > 0 {
|
||||
summary["collected"] = r.Collected
|
||||
}
|
||||
if len(r.Flat) > 0 {
|
||||
summary["flat"] = r.Flat
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -425,13 +493,17 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", skillsFailureHint())
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills (%s layout)\n", symOK(), len(r.Official), r.Layout)
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally (%s layout)\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted), r.Layout)
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsFailureHint() string {
|
||||
return "Retry: lark-cli update --force. To switch to separate top-level skills: lark-cli update --skills-layout separate (this saves layout=separate and clears saved flat_skills)."
|
||||
}
|
||||
|
||||
@@ -996,7 +996,7 @@ func newTestIO() *cmdutil.IOStreams {
|
||||
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
@@ -1006,7 +1006,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
@@ -1015,6 +1015,27 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_MissingLayoutDoesNotDedup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want successful sync when state lacks layout", got)
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsCommandOverride not called, want resync when state lacks layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
@@ -1027,7 +1048,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
@@ -1039,7 +1060,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
@@ -1050,6 +1071,12 @@ func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
if state.Layout != skillscheck.LayoutSeparate {
|
||||
t.Errorf("state.Layout = %q, want %q", state.Layout, skillscheck.LayoutSeparate)
|
||||
}
|
||||
if len(state.FlatSkills) != 0 {
|
||||
t.Errorf("state.FlatSkills = %#v, want empty", state.FlatSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
@@ -1064,7 +1091,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
@@ -1077,6 +1104,57 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillsSyncOptions_UsesStateAsFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.21",
|
||||
Layout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: []string{"lark-doc"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
layout, flat := resolveSkillsSyncOptions("", "", false)
|
||||
if layout != skillscheck.LayoutHybrid {
|
||||
t.Fatalf("layout = %q, want %q", layout, skillscheck.LayoutHybrid)
|
||||
}
|
||||
if len(flat) != 1 || flat[0] != "lark-doc" {
|
||||
t.Fatalf("flat = %#v, want [lark-doc]", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsRejectsSuiteMode(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{SkillsLayout: "suite"})
|
||||
if err == nil {
|
||||
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
|
||||
}
|
||||
if validation.Param != "--skills-layout" {
|
||||
t.Fatalf("validation.Param = %q, want --skills-layout", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsRejectsSharedFlatInHybrid(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{
|
||||
SkillsLayout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: "lark-shared",
|
||||
FlatSet: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
|
||||
}
|
||||
if validation.Param != "--flat-skills" {
|
||||
t.Fatalf("validation.Param = %q, want --flat-skills", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
@@ -1357,7 +1435,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ const (
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
isolatedSkillsSourceURL = "https://open.feishu.cn/lark-cli/isolated-skills"
|
||||
isolatedSkillsFallback = "larksuite/cli/isolated-skills"
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -242,6 +244,14 @@ func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSuiteSkill() *NpmResult {
|
||||
r := u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall(isolatedSkillsFallback, []string{"lark-suite"})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
@@ -208,6 +208,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
{
|
||||
name: "install isolated suite skill",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn/lark-cli/isolated-skills -s lark-suite -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -238,6 +245,34 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) {
|
||||
called := []string{}
|
||||
u := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), isolatedSkillsSourceURL) {
|
||||
r.Err = fmt.Errorf("isolated source unavailable")
|
||||
}
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := u.InstallSuiteSkill()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("calls = %#v, want primary and fallback", called)
|
||||
}
|
||||
if !strings.Contains(called[0], isolatedSkillsSourceURL) {
|
||||
t.Fatalf("primary call = %q, want isolated source", called[0])
|
||||
}
|
||||
if !strings.Contains(called[1], isolatedSkillsFallback) {
|
||||
t.Fatalf("fallback call = %q, want isolated GitHub fallback", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
|
||||
374
internal/skillscheck/layout.go
Normal file
374
internal/skillscheck/layout.go
Normal file
@@ -0,0 +1,374 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
LayoutSeparate = "separate"
|
||||
LayoutHybrid = "hybrid"
|
||||
|
||||
suiteSkillName = "lark-suite"
|
||||
sharedSkillName = "lark-shared"
|
||||
suiteRoutesPlaceholder = "<!-- LARK_SUITE_ROUTES -->"
|
||||
)
|
||||
|
||||
type GlobalSkillInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func NormalizeLayout(layout string) (string, bool) {
|
||||
switch strings.TrimSpace(layout) {
|
||||
case "", LayoutSeparate:
|
||||
return LayoutSeparate, true
|
||||
case LayoutHybrid:
|
||||
return LayoutHybrid, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFlatSkills(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name != "" {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseGlobalSkillInfosJSON(text string) []GlobalSkillInfo {
|
||||
infos, _ := parseGlobalSkillInfosJSON(text)
|
||||
return infos
|
||||
}
|
||||
|
||||
func parseGlobalSkillInfosJSON(text string) ([]GlobalSkillInfo, bool) {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
seen := map[string]GlobalSkillInfo{}
|
||||
for _, skill := range skills {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
path := strings.TrimSpace(skill.Path)
|
||||
if name == "" || path == "" || !skillNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
seen[name] = GlobalSkillInfo{Name: name, Path: path}
|
||||
}
|
||||
|
||||
out := make([]GlobalSkillInfo, 0, len(seen))
|
||||
for _, info := range seen {
|
||||
out = append(out, info)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, true
|
||||
}
|
||||
|
||||
func installedSkillNamesFromInfos(infos []GlobalSkillInfo) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, info := range infos {
|
||||
seen[info.Name] = true
|
||||
if info.Name == suiteSkillName {
|
||||
for _, subskill := range listSuiteSubskills(info.Path) {
|
||||
seen[subskill] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func listSuiteSubskills(suitePath string) []string {
|
||||
entries, err := os.ReadDir(filepath.Join(suitePath, "references", "subskills"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(entry.Name())
|
||||
if name != "" && skillNamePattern.MatchString(name) {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func normalOfficialSkills(skills []string) []string {
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(skills) {
|
||||
if skill != suiteSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deletedOfficialSkills(official, local []string, previous *SkillsState, stateReadable, force bool, layout string) []string {
|
||||
if force || !stateReadable || previous == nil {
|
||||
return []string{}
|
||||
}
|
||||
officialSet := toSet(official)
|
||||
localSet := toSet(local)
|
||||
deleted := map[string]bool{}
|
||||
for _, skill := range previous.OfficialSkills {
|
||||
if !officialSet[skill] || localSet[skill] {
|
||||
continue
|
||||
}
|
||||
if layout != LayoutSeparate && skill == sharedSkillName {
|
||||
continue
|
||||
}
|
||||
deleted[skill] = true
|
||||
}
|
||||
return sortedKeys(deleted)
|
||||
}
|
||||
|
||||
func suiteEffectiveSkills(official []string, deleted map[string]bool) []string {
|
||||
out := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if !deleted[skill] {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(out)
|
||||
}
|
||||
|
||||
func resolveHybridSkillSets(layout string, requestedFlat, official []string, skippedDeleted []string) ([]string, []string, error) {
|
||||
if layout == LayoutSeparate {
|
||||
return []string{}, []string{}, nil
|
||||
}
|
||||
|
||||
officialSet := toSet(official)
|
||||
deletedSet := toSet(skippedDeleted)
|
||||
configuredFlat := map[string]bool{}
|
||||
effectiveFlat := map[string]bool{}
|
||||
for _, skill := range uniqueSorted(requestedFlat) {
|
||||
if skill == sharedSkillName {
|
||||
return nil, nil, fmt.Errorf("%s cannot be selected by --flat-skills", sharedSkillName)
|
||||
}
|
||||
if !officialSet[skill] {
|
||||
return nil, nil, fmt.Errorf("flat skill %q is not in official skills", skill)
|
||||
}
|
||||
configuredFlat[skill] = true
|
||||
if !deletedSet[skill] {
|
||||
effectiveFlat[skill] = true
|
||||
}
|
||||
}
|
||||
|
||||
collected := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if skill == sharedSkillName {
|
||||
collected = append(collected, skill)
|
||||
continue
|
||||
}
|
||||
if deletedSet[skill] || effectiveFlat[skill] {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, skill)
|
||||
}
|
||||
return sortedKeys(configuredFlat), uniqueSortedWithFirst(collected, sharedSkillName), nil
|
||||
}
|
||||
|
||||
func uniqueSortedWithFirst(values []string, first string) []string {
|
||||
seen := toSet(values)
|
||||
if !seen[first] {
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
delete(seen, first)
|
||||
return append([]string{first}, sortedKeys(seen)...)
|
||||
}
|
||||
|
||||
func assembleSuiteLayout(layout string, collected []string, keepSharedTopLevel bool, infos []GlobalSkillInfo) error {
|
||||
if layout == LayoutSeparate {
|
||||
return nil
|
||||
}
|
||||
|
||||
infoByName := map[string]GlobalSkillInfo{}
|
||||
for _, info := range infos {
|
||||
infoByName[info.Name] = info
|
||||
}
|
||||
suiteInfo, ok := infoByName[suiteSkillName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s was not installed from isolated skills source", suiteSkillName)
|
||||
}
|
||||
|
||||
subskillsDir := filepath.Join(suiteInfo.Path, "references", "subskills")
|
||||
if err := os.RemoveAll(subskillsDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(subskillsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, skill := range collected {
|
||||
info, ok := infoByName[skill]
|
||||
if !ok {
|
||||
return fmt.Errorf("suite subskill %q was not installed", skill)
|
||||
}
|
||||
dst := filepath.Join(subskillsDir, skill)
|
||||
if keepSharedTopLevel && skill == sharedSkillName {
|
||||
if err := copyDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := moveDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return renderSuiteRoutes(suiteInfo.Path, collected)
|
||||
}
|
||||
|
||||
func renderSuiteRoutes(suitePath string, collected []string) error {
|
||||
skillPath := filepath.Join(suitePath, "SKILL.md")
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := normalizeSuiteTemplateText(string(data))
|
||||
routes := []string{}
|
||||
for _, skill := range collected {
|
||||
desc := skillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md"))
|
||||
if desc == "" {
|
||||
desc = skill
|
||||
}
|
||||
routes = append(routes, fmt.Sprintf("- %s: %s", skill, desc))
|
||||
}
|
||||
if !strings.Contains(text, suiteRoutesPlaceholder) {
|
||||
return fmt.Errorf("%s route placeholder not found", suiteSkillName)
|
||||
}
|
||||
text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1)
|
||||
return os.WriteFile(skillPath, []byte(text), 0o644)
|
||||
}
|
||||
|
||||
func normalizeSuiteTemplateText(text string) string {
|
||||
text = strings.ReplaceAll(text, "--collected-skills", "--flat-skills")
|
||||
oldShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
newShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。"
|
||||
return strings.ReplaceAll(text, oldShared, newShared)
|
||||
}
|
||||
|
||||
func skillDescription(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return ""
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "---" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "description:") {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "description:"))
|
||||
if value == ">" || value == "|" {
|
||||
return foldedYAMLScalar(lines[i+2:])
|
||||
}
|
||||
return strings.Trim(value, `"'`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func foldedYAMLScalar(lines []string) string {
|
||||
parts := []string{}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "---" || !isIndentedYAMLLine(line) {
|
||||
break
|
||||
}
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func isIndentedYAMLLine(line string) bool {
|
||||
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
||||
}
|
||||
|
||||
func moveDir(src, dst string) error {
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(src)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", src)
|
||||
}
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
@@ -23,10 +23,12 @@ var ErrUnreadableState = errors.New("skills state is unreadable")
|
||||
|
||||
type SkillsState struct {
|
||||
Version string `json:"version"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedOfficialSkills []string `json:"added_official_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
FlatSkills []string `json:"flat_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -76,6 +78,14 @@ func ReadSyncedVersion() (string, bool) {
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func ReadSyncedVersionAndLayout() (version string, layout string, ok bool) {
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return state.Version, state.Layout, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
@@ -89,4 +99,7 @@ func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
if s.FlatSkills == nil {
|
||||
s.FlatSkills = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ var (
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
Layout string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
@@ -195,7 +196,18 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
official := normalOfficialSkills(input.OfficialSkills)
|
||||
layout, _ := NormalizeLayout(input.Layout)
|
||||
skippedDeleted := deletedOfficialSkills(official, input.LocalSkills, input.PreviousState, input.StateReadable, input.Force, layout)
|
||||
if layout != LayoutSeparate {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: suiteEffectiveSkills(official, toSet(skippedDeleted)),
|
||||
Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable),
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
@@ -229,19 +241,12 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: uniqueSorted(newAddedOfficial),
|
||||
SkippedDeleted: skipped,
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,13 +257,16 @@ type SkillsRunner interface {
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
InstallSuiteSkill() *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
Version string
|
||||
Layout string
|
||||
FlatSkills []string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
@@ -271,6 +279,9 @@ type SyncResult struct {
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
Layout string
|
||||
Flat []string
|
||||
Collected []string
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
@@ -280,16 +291,26 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
layout, ok := NormalizeLayout(opts.Layout)
|
||||
if !ok {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("unsupported skills layout %q", opts.Layout)}
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to discover official skills for %s layout: %s", layout, reason), reason)
|
||||
}
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to list local skills for %s layout", layout), "local skills list failed or parsed as empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
}
|
||||
|
||||
@@ -302,12 +323,17 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
flat, collected, err := resolveHybridSkillSets(layout, opts.FlatSkills, plan.OfficialSkills, plan.SkippedDeleted)
|
||||
if err != nil {
|
||||
return &SyncResult{Action: "failed", Err: err, Official: plan.OfficialSkills, Force: opts.Force, Layout: layout}
|
||||
}
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
@@ -316,25 +342,59 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
Layout: layout,
|
||||
Flat: flat,
|
||||
Collected: collected,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("no target skills to assemble %s layout", layout), "toUpdate skills empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to install skills for %s layout: %s", layout, resultDetail(installResult)), resultDetail(installResult))
|
||||
}
|
||||
return fallbackFullInstall(opts, resultDetail(installResult), official)
|
||||
}
|
||||
}
|
||||
if layout != LayoutSeparate {
|
||||
installSuiteResult := opts.Runner.InstallSuiteSkill()
|
||||
if installSuiteResult == nil || installSuiteResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to install %s from isolated skills source: %s", suiteSkillName, resultDetail(installSuiteResult))
|
||||
result.Detail = resultDetail(installSuiteResult)
|
||||
return result
|
||||
}
|
||||
infosResult := opts.Runner.ListGlobalSkillsJSON()
|
||||
if infosResult == nil || infosResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to list installed skills for %s assembly: %s", suiteSkillName, resultDetail(infosResult))
|
||||
result.Detail = resultDetail(infosResult)
|
||||
return result
|
||||
}
|
||||
infos := ParseGlobalSkillInfosJSON(infosResult.Stdout.String())
|
||||
keepSharedTopLevel := layout == LayoutHybrid && len(flat) > 0
|
||||
if err := assembleSuiteLayout(layout, collected, keepSharedTopLevel, infos); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to assemble %s layout: %w", layout, err)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedOfficialSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
FlatSkills: stateFlatSkills(layout, flat),
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
@@ -346,6 +406,16 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func failedSync(layout string, force bool, err error, detail string) *SyncResult {
|
||||
return &SyncResult{
|
||||
Action: "failed",
|
||||
Err: err,
|
||||
Detail: detail,
|
||||
Force: force,
|
||||
Layout: layout,
|
||||
}
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
@@ -383,8 +453,9 @@ func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
infos, valid := parseGlobalSkillInfosJSON(jsonResult.Stdout.String())
|
||||
if valid {
|
||||
return installedSkillNamesFromInfos(infos), true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +482,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
@@ -419,11 +491,13 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
|
||||
Detail: reason + "\n" + resultDetail(installResult),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: LayoutSeparate,
|
||||
OfficialSkills: official,
|
||||
UpdatedSkills: official,
|
||||
AddedOfficialSkills: official,
|
||||
@@ -439,6 +513,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason + "\nstate write failed: " + writeErr.Error(),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,9 +525,38 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
func stateFlatSkills(layout string, requested []string) []string {
|
||||
if layout != LayoutHybrid {
|
||||
return []string{}
|
||||
}
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(requested) {
|
||||
if skill != sharedSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string {
|
||||
previousOfficial := []string{}
|
||||
if stateReadable && previous != nil {
|
||||
previousOfficial = previous.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
added := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
added = append(added, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(added)
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -216,8 +217,10 @@ type fakeSkillsRunner struct {
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installSuiteErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
installedSuite int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
@@ -319,6 +322,13 @@ func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSuiteSkill() *selfupdate.NpmResult {
|
||||
f.installedSuite++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installSuiteErr
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -574,7 +584,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
@@ -585,14 +595,15 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %#v, want one incremental install", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail"})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +708,115 @@ func TestSyncSkills_NilRunnerFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridAssemblesSuiteAndMovesCollectedSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if runner.installedSuite != 1 {
|
||||
t.Fatalf("installedSuite = %d, want 1", runner.installedSuite)
|
||||
}
|
||||
assertStrings(t, result.Flat, []string{"lark-calendar"})
|
||||
assertStrings(t, result.Collected, []string{"lark-shared", "lark-doc"})
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-doc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-doc missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-shared missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-calendar"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("flat lark-calendar missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-doc"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("collected lark-doc still exists at top level or unexpected err: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("lark-shared should stay top-level when flat set is non-empty: %v", err)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Layout != LayoutHybrid {
|
||||
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutHybrid)
|
||||
}
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridWithNoFlatSkillsDoesNotKeepSharedTopLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lark-shared should not stay top-level when flat set is empty; err: %v", err)
|
||||
}
|
||||
if result.Flat == nil || len(result.Flat) != 0 {
|
||||
t.Fatalf("result.Flat = %#v, want empty slice", result.Flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridRejectsSharedFlatSkill(t *testing.T) {
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-shared"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-shared"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "lark-shared") {
|
||||
t.Fatalf("SyncSkills() err = %v, want lark-shared validation error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -733,6 +853,43 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSuiteTemplateTextRewritesLegacyFlatSkillWording(t *testing.T) {
|
||||
input := "`lark-shared` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
got := normalizeSuiteTemplateText(input)
|
||||
if strings.Contains(got, "--collected-skills") {
|
||||
t.Fatalf("normalized text still contains legacy flag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--flat-skills") {
|
||||
t.Fatalf("normalized text missing --flat-skills: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份") {
|
||||
t.Fatalf("normalized text missing current lark-shared rule: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDescriptionSupportsFoldedYAMLScalar(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "SKILL.md")
|
||||
content := `---
|
||||
name: lark-whiteboard
|
||||
description: >
|
||||
飞书画板:查询和编辑飞书云文档中的画板。
|
||||
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := skillDescription(path)
|
||||
want := "飞书画板:查询和编辑飞书云文档中的画板。 当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。"
|
||||
if got != want {
|
||||
t.Fatalf("skillDescription() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -740,6 +897,38 @@ func assertStrings(t *testing.T, got, want []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSkill(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := fmt.Sprintf("---\nname: %s\ndescription: %s description\n---\n", name, name)
|
||||
if name == suiteSkillName {
|
||||
content = "---\nname: lark-suite\ndescription: Lark suite\n---\n<!-- LARK_SUITE_ROUTES -->\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func globalSkillsJSONFromPaths(paths map[string]string) string {
|
||||
names := make([]string, 0, len(paths))
|
||||
for name := range paths {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, paths[name])
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 5 * time.Second
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
33
isolated-skills/lark-suite/SKILL.md
Normal file
33
isolated-skills/lark-suite/SKILL.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: lark-suite
|
||||
version: 0.1.0
|
||||
description: 飞书/Lark 聚合能力入口:当用户需求涉及本文件列出的任一飞书能力时使用;仅负责选择并加载已安装到本 suite 的 lark-* 子能力,不替代具体子能力的操作细节。
|
||||
metadata:
|
||||
requires:
|
||||
bins:
|
||||
- lark-cli
|
||||
---
|
||||
|
||||
# Lark Suite
|
||||
|
||||
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
|
||||
|
||||
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 根据用户意图从下方路由表选择一个或多个子能力;即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
|
||||
2. 读取对应子能力说明,优先使用 `references/subskills/<skill-name>/SKILL.md`。
|
||||
3. 如果目标能力没有出现在本文件中,不代表当前环境不可用;检查是否存在相关的独立 `lark-*` skill。
|
||||
4. 如果已选中的子能力说明列出必读、前置或继续阅读的文件,只读取该子能力当前任务所需的前置文件;不要读取无关子能力。
|
||||
5. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`。
|
||||
|
||||
`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。
|
||||
|
||||
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
|
||||
|
||||
## 能力路由
|
||||
|
||||
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
|
||||
|
||||
<!-- LARK_SUITE_ROUTES -->
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.63",
|
||||
"version": "1.0.64",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
|
||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
261
shortcuts/doc/docs_history.go
Normal file
261
shortcuts/doc/docs_history.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type docsHistoryListSpec struct {
|
||||
Doc documentRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type docsHistoryRevertSpec struct {
|
||||
Doc documentRef
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type docsHistoryRevertStatusSpec struct {
|
||||
Doc documentRef
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||
ref, err := parseDocumentRef(raw)
|
||||
if err != nil {
|
||||
return documentRef{}, err
|
||||
}
|
||||
if ref.Kind == "doc" {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||
}
|
||||
if version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||
}
|
||||
|
||||
var DocsHistoryList = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-list",
|
||||
Description: "List Lark document history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-list"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: list document history versions").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryListParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||
docsHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Lark document to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: revert document history").
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Body(docsHistoryRevertBody(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||
nil,
|
||||
docsHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevertStatus = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Lark document history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: get document history revert status").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryStatusParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||
docsHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
340
shortcuts/doc/docs_history_test.go
Normal file
340
shortcuts/doc/docs_history_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDocsHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "list rejects legacy doc URL",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||
param: "--doc",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-numeric history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-positive history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: DocsHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
|
||||
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != tt.category {
|
||||
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||
}
|
||||
if problem.Subtype != tt.subtype {
|
||||
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryList, []string{
|
||||
"+history-list",
|
||||
"--doc", "doxcnHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--doc", "doxcnHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": []interface{}{"blk_1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--doc", "doxcnHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "partial_failed" {
|
||||
t.Fatalf("status = %#v, want partial_failed", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
failed, _ := data["failed_block_tokens"].([]interface{})
|
||||
if len(failed) != 1 || failed[0] != "blk_1" {
|
||||
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestDocsHistoryURLValidationMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only supports docx documents") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||
case "update":
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +fetch --help"
|
||||
case "update":
|
||||
return "lark-cli docs +update --help"
|
||||
case "history-list":
|
||||
return "lark-cli docs +history-list --help"
|
||||
case "history-revert":
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
DocMediaInsert,
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
|
||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
dr := common.NewDryRunAPI()
|
||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
||||
}
|
||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
return common.NewDryRunAPI().
|
||||
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
|
||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
||||
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||
}
|
||||
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
||||
return body
|
||||
}
|
||||
|
||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
out["from_speaker_id"] = fromSpeakerID
|
||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
||||
out["from_speaker_input"] = fromSpeakerInput
|
||||
}
|
||||
} else {
|
||||
out["from_user_id"] = fromUserID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerInput != "" {
|
||||
return fromSpeakerInput
|
||||
}
|
||||
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerID != "" {
|
||||
return fromSpeakerID
|
||||
}
|
||||
|
||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"speakers": []interface{}{
|
||||
map[string]interface{}{
|
||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
||||
"name": "说话人1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||
// straight through without a second speakerlist call. If the code still
|
||||
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
FromSpeakerInput string `json:"from_speaker_input"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
||||
}
|
||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||
}
|
||||
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
if strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type minuteSpeaker struct {
|
||||
SpeakerID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
||||
}
|
||||
|
||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "speakers")
|
||||
speakers := make([]minuteSpeaker, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
||||
}
|
||||
return speakers, nil
|
||||
}
|
||||
|
||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var matches []minuteSpeaker
|
||||
for _, s := range speakers {
|
||||
if s.Name == name {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
||||
"no speaker named %q in minute transcript", name).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
||||
case 1:
|
||||
return matches[0].SpeakerID, nil
|
||||
default:
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.SpeakerID
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
||||
// The input may already be an opaque speaker_id, or a display name that requires
|
||||
// an internal speaker-list fetch.
|
||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, s := range speakers {
|
||||
if s.SpeakerID == input {
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
return resolveSpeakerIDByName(speakers, input)
|
||||
}
|
||||
|
||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID != "" {
|
||||
return "", fromUserID, nil
|
||||
}
|
||||
|
||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
||||
return fromSpeakerID, "", err
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
||||
speakers := []minuteSpeaker{
|
||||
{SpeakerID: "id_a", Name: "Alice"},
|
||||
{SpeakerID: "id_b", Name: "Bob"},
|
||||
{SpeakerID: "id_c", Name: "Alice"},
|
||||
}
|
||||
|
||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
||||
if err != nil || id != "id_b" {
|
||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
|
||||
cmd, _, err := program.Find([]string{"docs", name})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s shortcut: %v", name, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != name {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
|
||||
}
|
||||
if cmd.Flags().Lookup("api-version") != nil {
|
||||
t.Fatalf("docs %s should not expose --api-version", name)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
|
||||
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
|
||||
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
|
||||
---
|
||||
|
||||
# docs
|
||||
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML(可用 callout、grid、checkbox 等富 block)。
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
@@ -39,12 +39,14 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
@@ -68,6 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||
|
||||
107
skills/lark-doc/references/lark-doc-history.md
Normal file
107
skills/lark-doc/references/lark-doc-history.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# docs history(历史版本与回滚)
|
||||
|
||||
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||
|
||||
## 安全流程
|
||||
|
||||
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。
|
||||
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。
|
||||
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
|
||||
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。
|
||||
5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。
|
||||
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
|
||||
|
||||
## 按 revision_id 或时间点回滚
|
||||
|
||||
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
|
||||
|
||||
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
|
||||
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
|
||||
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
|
||||
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`。
|
||||
|
||||
候选确认时使用类似格式:
|
||||
|
||||
```text
|
||||
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
|
||||
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
|
||||
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出历史版本
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
|
||||
|
||||
# 翻页
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||
|
||||
# 回滚到指定 history_version_id(默认等待 30000ms)
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
|
||||
|
||||
# 只发起任务,不等待
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||
|
||||
# 查询回滚任务状态
|
||||
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|-|-|-|-|
|
||||
| `+history-list` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
|
||||
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
|
||||
| `+history-revert` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
|
||||
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
|
||||
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
|
||||
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
|
||||
|
||||
## 返回值要点
|
||||
|
||||
`+history-list` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"revision_id": 42,
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": 1,
|
||||
"name": "版本名",
|
||||
"description": "版本说明",
|
||||
"editor_ids": ["ou_xxx"]
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"page_token": "page_token"
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"status": "running",
|
||||
"history_version_id": "11",
|
||||
"poll_after_ms": 10000
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert-status` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": ["blk_xxx"]
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。
|
||||
113
skills/lark-doc/references/lark-doc-mindnote.md
Normal file
113
skills/lark-doc/references/lark-doc-mindnote.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 飞书思维笔记(Mindnote)
|
||||
|
||||
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
|
||||
|
||||
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
|
||||
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
|
||||
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先看命令帮助
|
||||
lark-cli mindnotes nodes list --help
|
||||
lark-cli mindnotes nodes create --help
|
||||
|
||||
# 读取节点列表
|
||||
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
|
||||
|
||||
# 创建子节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
|
||||
|
||||
# 更新已有节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
### `mindnotes nodes list`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
|
||||
返回重点:`data.nodes` 中常见字段有 `node_id`、`parent_id`、`texts`、`notes`、`images`、`finish`、`highlight`。
|
||||
|
||||
### `mindnotes nodes create`
|
||||
|
||||
命令参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
| `--data` | 是 | JSON 请求体 |
|
||||
|
||||
请求体字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `client_token` | 否 | 幂等 token,建议写操作传入;推荐使用时间戳或 UUID |
|
||||
| `nodes` | 是 | 待创建或更新的节点数组 |
|
||||
| `nodes[].node_id` | 否 | 节点 ID;传入已有 `node_id` 时表示更新对应节点 |
|
||||
| `nodes[].parent_id` | 否 | 父节点 ID;创建子节点时传入 |
|
||||
| `nodes[].texts` | 否 | 节点正文富文本数组 |
|
||||
| `nodes[].notes` | 否 | 节点备注富文本数组 |
|
||||
| `nodes[].images` | 否 | 节点图片列表 |
|
||||
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
|
||||
| `nodes[].finish` | 否 | 节点完成状态 |
|
||||
|
||||
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
|
||||
|
||||
```json
|
||||
[{"element_type":"text","text":{"content":"节点内容"}}]
|
||||
```
|
||||
|
||||
### 节点图片(`nodes[].images`)
|
||||
|
||||
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
|
||||
|
||||
```bash
|
||||
# 先上传图片,拿到 token
|
||||
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
|
||||
|
||||
# 再把 token 写进节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地图片路径 |
|
||||
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
|
||||
| `--parent-node` | 是 | 传 Mindnote 的 token |
|
||||
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
1. 先判断用户目标是不是“新建一个思维笔记”。
|
||||
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
3. 如果是操作已有思维笔记,先通过 token 类别判断。
|
||||
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`。
|
||||
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`。
|
||||
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`。
|
||||
7. 再执行 `mindnotes nodes create`。
|
||||
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID,避免重试时重复创建或重复更新。
|
||||
|
||||
> [!CAUTION]
|
||||
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
|
||||
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
||||
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
|
||||
|
||||
@@ -13,6 +13,13 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
## 统计范围
|
||||
|
||||
先判断用户要求的是**整篇文档**还是**局部内容**:
|
||||
|
||||
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
|
||||
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
@@ -36,6 +43,15 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 字数遵循校验
|
||||
|
||||
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
|
||||
|
||||
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`<y`→`(-∞, y-1]`;`x-y`→`[x, y]`;`x 字左右`→`[round(0.9x), round(1.1x)]`
|
||||
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
|
||||
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
|
||||
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
|
||||
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent(见步骤三)
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
@@ -16,44 +16,31 @@
|
||||
|
||||
## 典型 Code-Act Loop 流程
|
||||
|
||||
### 步骤一:规划与初始创建(串行)
|
||||
### 步骤一:规划与撰写(单 Agent 串行)
|
||||
|
||||
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
||||
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
||||
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
|
||||
3. `docs +create` 创建并撰写:
|
||||
- **短文档**:一次写入完整内容
|
||||
- **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**用 `block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
|
||||
- ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
|
||||
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
|
||||
- ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
|
||||
|
||||
### 步骤二:分段撰写(并行 Agent)
|
||||
### 步骤二:整合审查与画板识别(串行)
|
||||
|
||||
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
|
||||
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
|
||||
- `lark-doc-xml.md` 和 `lark-doc-style.md` 的完整路径(Agent 须先读取)
|
||||
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
|
||||
4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
|
||||
6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
### 步骤三:整合审查与画板识别(串行)
|
||||
### 步骤三:画板处理与润色
|
||||
|
||||
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||
8. 由**主 Agent 自行润色**(不另起内容子 Agent,正文始终一人维护):文字密集且不易读时,优先拆段、加小标题或调整顺序——叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
### 步骤四:画板处理与润色(并行 Agent)
|
||||
### 步骤四:专项校验(按需执行)
|
||||
|
||||
8. **优先处理步骤三识别出的画板需求**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
|
||||
|
||||
@@ -1,86 +1,68 @@
|
||||
# 文档表达组件参考
|
||||
# 飞书文档写作原则
|
||||
|
||||
本文件说明飞书文档可用的结构化表达方式,供模型在需要时选择。它不是固定模板,也不是强制排版规范。
|
||||
写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件。
|
||||
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
|
||||
|
||||
默认原则:优先理解用户目标、受众、素材形态和已有文档风格,由模型自主决定结构、语气和视觉呈现。只有当用户明确要求“美化、重排版、做成报告/方案/看起来更专业”等,或内容本身明显需要结构化承载时,才主动使用下列组件。
|
||||
## 一、用户明确要求优先
|
||||
|
||||
## 一、核心原则
|
||||
用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
|
||||
|
||||
1. **服务内容,而非套模板**:先判断信息最自然的表达方式,再选择段落、列表、表格、分栏、画板等元素
|
||||
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
|
||||
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
|
||||
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
|
||||
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
|
||||
## 二、默认写连贯段落
|
||||
|
||||
## 二、元素选择指南
|
||||
用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
|
||||
|
||||
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
|
||||
| 内容 | 用什么 | ❌ 别 |
|
||||
|---|---|---|
|
||||
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
|
||||
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
|
||||
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
|
||||
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
|
||||
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
|
||||
| 纯短并列项(无描述,如材料清单) | 列表 | — |
|
||||
| 章节(内容成块、需在目录导航) | 标题层级 | — |
|
||||
|
||||
| 场景 | 可选表达方式 |
|
||||
|--------------------------------------------|---------------------------------------|
|
||||
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 |
|
||||
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
|
||||
- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
|
||||
- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
|
||||
- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
|
||||
- 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
|
||||
- **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
|
||||
|
||||
## 三、按体裁写
|
||||
|
||||
### 画板意图识别
|
||||
- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
|
||||
- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block(高亮块、分栏等),粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
|
||||
- **一般文档**:以可读为先,不堆砌结构。
|
||||
|
||||
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化:
|
||||
## 四、编号与层级
|
||||
|
||||
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|
||||
- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
|
||||
- 公文 / 正式材料常用:「一、→(一)→ 1.→(1)」(中文大层级 + 阿拉伯细分层级)。
|
||||
- 学术 / 技术 / 商业报告:「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
|
||||
- ⚠️ **「一、」只能配「(一)」;要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
|
||||
- **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号**;**不跳级**。
|
||||
- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
|
||||
- 简单的 1.2.3 并列项用原生 `<ol><li seq="auto">…</li></ol>` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
|
||||
|
||||
## 五、飞书特有组件,克制使用
|
||||
|
||||
- **高亮块 `<callout>`**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项,全文极少(0~1 个),不要每节导语 / 结论都做成高亮块。
|
||||
- **分栏 `<grid>`**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
|
||||
- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
|
||||
- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
|
||||
|
||||
| 语义 | 背景色 | 文字色 |
|
||||
|-|-|-|
|
||||
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
|
||||
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
|
||||
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
|
||||
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
|
||||
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
|
||||
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
|
||||
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
|
||||
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
|
||||
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
|
||||
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
|
||||
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
|
||||
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
|
||||
| 信息、说明 | `light-blue` | `blue` |
|
||||
| 成功、推荐 | `light-green` | `green` |
|
||||
| 警告 / 错误 / 风险 | `light-red` | `red` |
|
||||
| 注意、待确认 | `light-yellow` | `yellow` |
|
||||
| 中性、辅助 | `light-gray` | — |
|
||||
|
||||
**判断规则:**
|
||||
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
|
||||
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
|
||||
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
|
||||
## 六、写完自检
|
||||
|
||||
## 三、颜色语义
|
||||
|
||||
如果使用颜色,建议保持语义一致;不需要颜色时可以保持朴素文本风格:
|
||||
|
||||
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|
||||
|-|-|-|-|
|
||||
| 信息、说明 | ℹ️ "说明:" | `light-blue` | `blue` |
|
||||
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
|
||||
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
|
||||
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
|
||||
| 中性、辅助 | — | `light-gray` | — |
|
||||
|
||||
- 表头可使用 `background-color="light-gray"`,也可以保持默认样式
|
||||
- 关键指标如使用 `<span text-color="green/red">` 突出,建议同时用 ↑↓ 或 +/- 标注方向(色觉无障碍)
|
||||
|
||||
## 四、排版规范
|
||||
|
||||
- 标题层级、段落长度、列表嵌套和 Grid 列数应以可读性为准,避免过深层级和过宽分栏
|
||||
- 文档开头可以是结论、背景、摘要、问题陈述、目录或直接正文,不强制使用 `<callout>`
|
||||
|
||||
## 五、质量自检
|
||||
|
||||
生成内容后可以从以下角度自检,但不要把这些项当作硬性比例或固定模板:
|
||||
|
||||
| 指标 | 自检问题 |
|
||||
|-|-|
|
||||
| 信息表达 | 当前结构是否符合用户目标,而不是套用固定报告模板? |
|
||||
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多的问题? |
|
||||
| 风格匹配 | 是否延续了用户给定样例或已有文档风格? |
|
||||
| 组件必要性 | callout、grid、table、whiteboard 等是否真的提升理解? |
|
||||
| 保真度 | 改写时是否保留了原文事实、引用、图片、附件和资源块? |
|
||||
交付前快速回看:
|
||||
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
|
||||
- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
|
||||
- **标题是否滥用**:"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
|
||||
- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用(如「一、」配「1.1」)。
|
||||
- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 核心方法论 — Code-Act Loop
|
||||
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent(见步骤二)
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
@@ -23,33 +23,26 @@
|
||||
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
||||
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
|
||||
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 步骤二:定向改写(并行 Agent)
|
||||
### 步骤二:定向改写(单 Agent 串行)
|
||||
|
||||
5. **优先处理步骤一识别出的画板候选段落**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:
|
||||
5. **优先处理步骤一识别出的画板候选段落**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||
6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
|
||||
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
||||
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
|
||||
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
|
||||
- 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`)
|
||||
- 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
|
||||
|
||||
### 步骤三:验证(串行)
|
||||
|
||||
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
|
||||
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果
|
||||
8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
|
||||
|
||||
## Agent 子任务要求
|
||||
### 步骤四:专项校验(按需执行)
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
> 错误:`lark-cli drive +search 方案`
|
||||
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
||||
>
|
||||
> **`--query` 最长 30 个字符**:按字符数(Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`,**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
|
||||
>
|
||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||
|
||||
### 自然语言 → 命令映射速查
|
||||
@@ -101,7 +103,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览 |
|
||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览。**长度上限 30 个字符(按 Unicode 码点算,中文每字算 1 个,与 ASCII 同口径);超过 30 服务端直接报 `99992402 field validation failed`,不会截断** |
|
||||
| `--page-size <n>` | 否 | 每页数量,默认 15,最大 20。超过 20 自动 clamp;非正数(≤0)回落 15;**非数字值直接返回 validation 错误** |
|
||||
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
||||
| `--format` | 否 | `json`(默认)/ `pretty` |
|
||||
|
||||
@@ -81,6 +81,8 @@ lark-cli minutes +speaker-replace \
|
||||
|
||||
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
||||
|
||||
`+speaker-replace` **不会**自己请求 speakerlist:`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist,务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found)。
|
||||
|
||||
### 2. 新说话人必须是 open_id
|
||||
|
||||
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: [ "lark-cli" ]
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
@@ -12,36 +12,198 @@ metadata:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 指引 |
|
||||
|----------|------|
|
||||
| 读取 / 分析本地 PPTX 内容 | 文本用 `python -m markitdown presentation.pptx`;视觉总览用 `python3 scripts/thumbnail.py presentation.pptx`;原始 OOXML 用 `python3 scripts/office/unpack.py presentation.pptx unpacked/` |
|
||||
| 从模板创建或编辑已有本地 PPTX | 先读 `lark-slides-pptx-template-workflows.md` |
|
||||
| 从零新建飞书在线 PPT | 先读 `lark-slides-create-workflows.md` |
|
||||
| 获取在线 slides 内容、读取 / 分析已有在线 PPT | XML 内容优先用 `slides +xml-get` 保存到文件;页面视觉内容用 `slides +screenshot`,详见 `lark-slides-screenshot.md` |
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
## 读取 / 分析内容
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
### 在线 Slides
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
|
||||
|
||||
```bash
|
||||
# 读取完整 XML 内容,优先保存到文件再分析
|
||||
lark-cli slides +xml-get --as user --presentation slides_example_presentation_id --output presentation.xml --json
|
||||
|
||||
# 获取页面截图;必须指定 --slide-number 或 --slide-id,多个页面可重复传 --slide-number
|
||||
lark-cli slides +screenshot --as user --presentation slides_example_presentation_id --slide-number 1 --output-dir screenshots --json
|
||||
lark-cli auth login --domain slides
|
||||
```
|
||||
|
||||
在线 Slides 的截图参数和页码语义详见 [`lark-slides-screenshot.md`](references/lark-slides-screenshot.md);需要继续编辑在线 Slides 时,按 `lark-slides-create-workflows.md` / `lark-slides-replace-workflows.md` 选择创建或替换流程。
|
||||
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
|
||||
|
||||
## 编辑 PPTX 工作流
|
||||
**执行规则**:
|
||||
|
||||
**完整流程先读 [`lark-slides-pptx-template-workflows.md`](references/lark-slides-pptx-template-workflows.md)。**
|
||||
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
|
||||
## 从零创建
|
||||
## 执行前必做
|
||||
|
||||
**完整流程先读 [`lark-slides-create-workflows.md`](references/lark-slides-create-workflows.md)。**
|
||||
> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
|
||||
|
||||
当没有本地 PPTX 模板 / 参考演示文稿,或目标是新建飞书 / Lark 在线 Slides 而不是本地 `.pptx` 文件时,使用该流程。
|
||||
高频只读:
|
||||
|
||||
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
|
||||
- [planning-layer.md](references/planning-layer.md)(新建 / 大幅改写)
|
||||
- [visual-planning.md](references/visual-planning.md)(新建 / 大幅改写)
|
||||
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
|
||||
|
||||
开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
|
||||
|
||||
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
|
||||
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重,1-2 个辅助色承担结构和分区,1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
- **双栏结构**:左文右图或左图右文,视觉区域占 35-45% 宽度。
|
||||
- **图标行**:图标在色块或圆形底中,右侧是短标题和一句解释。
|
||||
- **2x2 / 2x3 网格**:适合能力、模块、风险、行动项,每格内容保持同等层级。
|
||||
- **半出血视觉**:图片或抽象形状占据左/右半屏,文字覆盖或贴边排布。
|
||||
- **大数字卡片**:关键指标用 60-72pt 数字,下面配 10-14pt 标签。
|
||||
- **对比列**:before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。
|
||||
- **时间线/流程图**:步骤用节点和箭头表达,流程方向必须一眼可见。
|
||||
|
||||
字体和间距建议:
|
||||
|
||||
- 标题 36-44pt,关键结论可更大;正文 14-18pt;注释 10-12pt。
|
||||
- 正文默认左对齐;只在封面、结尾或大号数字场景中使用居中。
|
||||
- 页面边距至少 40px;内容块之间保持 24-40px 间距,并在同一 deck 内保持一致。
|
||||
- 卡片内边距要真实留出空间,不要让文字贴边;对齐 shape 和文字时要考虑文本框 padding。
|
||||
|
||||
常见错误必须避免:
|
||||
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart、whiteboard 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
# 插到指定页之前:before_slide_id 必须在 --data body 里,与 slide 同级
|
||||
# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide ...>...</slide>' --arg before 'TARGET_SLIDE_ID' \
|
||||
'{slide:{content:$content}, before_slide_id:$before}')"
|
||||
```
|
||||
|
||||
> 渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
|
||||
### 大纲模板
|
||||
|
||||
生成大纲时使用以下格式,交给用户确认:
|
||||
|
||||
```text
|
||||
[PPT 标题] — [定位描述],面向 [目标受众]
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
3. [页面主题]:[要点描述]
|
||||
...
|
||||
N. 结尾页:[结尾文案]
|
||||
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -78,98 +240,35 @@ Slides (演示文稿)
|
||||
└── slide_id (页面唯一标识)
|
||||
```
|
||||
|
||||
## 身份选择
|
||||
## Shortcuts 与 API
|
||||
|
||||
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain slides
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
**执行规则**:
|
||||
## 核心规则
|
||||
|
||||
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 设计思路
|
||||
|
||||
### 内容先行
|
||||
|
||||
- 每页只服务一个核心观点。内容页标题应写成带判断的结论句,而不是主题标签;读者只看标题就能知道这一页要证明什么。
|
||||
- 受众和交付方式决定密度:演讲型 deck 更适合少字、强节奏、分步呈现;自读型 deck 必须在没有讲解和点击的情况下完整可读。
|
||||
- 并列观点要互不重叠、没有明显缺口,通常控制在 3-5 个,最多不要超过 7 个;排序只选一种逻辑:时间、结构或重要性。
|
||||
- 封面、章节页、内容页、结尾页承担不同任务。章节页只做过渡,不承载多点论证;短 deck 不要机械加入 agenda、Q&A 或多个收尾页。
|
||||
|
||||
### 视觉系统
|
||||
|
||||
- 先根据主题、行业、受众和交付方式推导视觉方向,再确定配色、字体、图形语言和页面密度;不要让用户在一堆抽象风格词里做选择。
|
||||
- 同一份 deck 要锁定一套视觉系统,并贯穿所有页面:主色、背景、正文颜色、强调色、标题处理、留白密度、图标/图形风格都要稳定。
|
||||
- 配色要有角色分工:`primary` 承担品牌/结构,`background` 承担页面基底,`text_primary` / `text_body` 保证阅读,`accent` 只用于关键数字、结论或行动点。
|
||||
- 不要默认蓝色商务风;如果一套配色换到完全不同主题仍然成立,说明它不够具体。
|
||||
- 背景策略要克制:纯色、渐变或图片三选一作为主背景,不要叠多层全页色块。深色、发光或科技感页面,应使用平整深色背景 + 局部发光元素,而不是半透明大渐变把页面洗白。
|
||||
- 所有文字、图标、线条和图表都必须与背景保持足够对比;弱化信息可以降低饱和度或透明度,但不能牺牲可读性。
|
||||
|
||||
### 字体与字号
|
||||
|
||||
- 标题字体可以有性格,正文字体必须清晰耐读;不要整份 deck 都默认 Arial。
|
||||
- 中英文混排时,字体族先写英文/拉丁字体,再写中文/CJK 字体,最后写通用 fallback;标题和正文各用一套稳定组合。
|
||||
- 字体选择要匹配视觉系统的类别和处理方式:衬线、无衬线、圆体、等宽、粗窄标题、全大写等风格不要随意互换。
|
||||
- 常用标题方向:`Playfair Display` / `EB Garamond` / `Lora` 适合编辑感和高级感;`Anton` / `Bebas Neue` / `Oswald` 适合强冲击标题;`DM Sans` / `Montserrat` / `Poppins` 适合现代产品和商业正文。
|
||||
- 常用中文方向:`思源宋体` 适合长文和编辑感;`思源黑体` / `黑体` 适合中性现代;`寒蝉德黑体` 适合工业和科技;`寒蝉全圆体` / `资源圆体` 适合温暖亲和;书法类字体只用于少量标题。
|
||||
|
||||
| 元素 | 建议字号 |
|
||||
|------|----------|
|
||||
| 封面标题 | 40-56px;纯标题页可到 64-96px |
|
||||
| 内容页标题 | 28-40px |
|
||||
| 副标题 / 分区标题 | 20-26px |
|
||||
| 正文一级 | 16-20px |
|
||||
| 正文二级 | 13-16px |
|
||||
| 注释 / 来源 | 11-13px |
|
||||
| Hero number | 80-140px |
|
||||
|
||||
不要为了填满空页面而盲目放大字体;页面显得空时,优先补充有意义的信息、调整构图或强化边缘对齐。
|
||||
|
||||
### 布局
|
||||
|
||||
- 先判断内容关系,再设计版式。比较、流程、时间线、循环、层级、矩阵、漏斗、整体-部分、因果等关系,应通过位置、对齐、分组、比例和流向直接表达。
|
||||
- 版式本身要承载逻辑:比较用并列和基线对齐,流程用方向和连接,层级用尺度和嵌套,矩阵用坐标和象限,因果用箭头和阅读顺序。
|
||||
- 每页都应围绕该页内容重新组织,不要从固定模板里盖章;同一 deck 可以复用视觉母题,但不要所有页面都是标题 + 三 bullets。
|
||||
- 页面要留呼吸感。内容块之间保持稳定间距,卡片内边距要真实存在;文字不要贴边,也不要被装饰线、图片或页脚挤压。
|
||||
- 正文默认左对齐;只有封面、章节页、结尾页、大号数字或少量仪式感页面适合居中。
|
||||
|
||||
### 视觉元素与图表
|
||||
|
||||
- 视觉元素必须承载意义或引导注意力,不做填空装饰。图片、图标、图表、表格、色块、连线都要解释内容关系、强调重点或改善阅读节奏。
|
||||
- 每个内容页至少应有一个非纯文本视觉锚点:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或抽象 shape 组合。
|
||||
- 信息图、截图、图表等素材要保持原始比例;不要为了塞进版面强行裁切或拉伸。装饰性照片可以更自由,但仍要服务主题和构图。
|
||||
- 有真实数据序列时,先写清图表要证明的 takeaway,再选择图表类型;一张图只表达一个核心结论。单个数字或两项简单对比,优先用大号数字 callout,不必硬画图。
|
||||
- 饼图 / 环图只适合表达明确的整体构成;不确定时优先使用排序条形图。多系列数据要控制数量,必要时合并为 Other。
|
||||
- 封面和结尾页的图片不要自带文字;文字应由 slide 渲染,避免图片中文字不可控、不可编辑或与语言风格冲突。
|
||||
|
||||
### 动效
|
||||
|
||||
- 动效服务节奏和注意力,不做炫技。只有在逐步解释、引导关键元素、展示流程 / 时间 / 变化时才使用。
|
||||
- 演讲型 deck 可以用少量 build 控制听众视线;自读型、正式汇报型、董事会/咨询风格 deck 应尽量静态,最多使用统一的页面转场。
|
||||
- 封面、章节页和结尾页默认静态。单页动效不超过 3 个 build,且同页尽量只使用一种效果;如果需要更多步骤,优先拆页。
|
||||
- 动效要让观众注意内容出现,而不是注意效果本身。优先使用淡入、出现、轻微上浮、擦入;避免旋转、弹跳、闪烁、远距离飞入等抢戏效果。
|
||||
|
||||
### 基于模板或已有 PPT 编辑
|
||||
|
||||
- 如果用户要求继续编辑、补页或修改已有 PPT,默认保留原页面内容、结构、字体、配色和视觉资产,只改用户要求的部分。
|
||||
- 除非用户明确要求重做,不要擅自美化、重排、加封面、换背景或从零复刻。
|
||||
- 如果用户把上传文件作为“参考风格”而不是“继续编辑原文件”,才可以抽取其视觉语言后重新创作。
|
||||
|
||||
### 避免事项
|
||||
|
||||
- 不要让版式先于内容;先判断这一页的逻辑关系,再决定几何结构。
|
||||
- 不要创建纯文本页;plain title + bullets 只能作为草稿,不是正式交付。
|
||||
- 不要只设计一页,其余页面保持 plain;视觉系统必须全篇贯彻,或者全篇保持有意克制。
|
||||
- 不要混用太多字体、字号、圆角、阴影和强调色;变化必须有层级意义。
|
||||
- 不要使用低对比文字、低对比图标或难以阅读的背景图。
|
||||
- 不要用图表承载多个结论,也不要因为有数字就机械画图。
|
||||
- 不要在标题下方画装饰强调线作为默认设计手法;优先用留白、背景色块、尺度、分区和对齐建立层级。
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
124
skills/lark-slides/references/asset-planning.md
Normal file
124
skills/lark-slides/references/asset-planning.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Asset Planning
|
||||
|
||||
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
|
||||
|
||||
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
|
||||
|
||||
## JSON Shape
|
||||
|
||||
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show how API gateway, planner, XML generator, and Slides API interact.",
|
||||
"suggested_query": "agent native slides runtime architecture diagram",
|
||||
"fallback_if_missing": "Draw grouped boxes connected by arrows with short labels."
|
||||
}
|
||||
```
|
||||
|
||||
For a page without a meaningful asset need, use:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "none",
|
||||
"purpose": "No external or simulated asset needed; the page is text-led.",
|
||||
"suggested_query": "",
|
||||
"fallback_if_missing": "Use typography, spacing, and simple accent shapes only."
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Asset Types
|
||||
|
||||
- `paper_figure`: figure from a paper or technical article.
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
- `none`: explicitly no asset needed.
|
||||
|
||||
Do not invent new asset types unless the user asks for a special visual format. If a need is close to these types, choose the closest one and explain the detail in `purpose`.
|
||||
|
||||
## Planning Guidance
|
||||
|
||||
Match asset type to slide role:
|
||||
|
||||
- `architecture-diagram` layout usually pairs with `architecture_diagram` or `flow_diagram`.
|
||||
- `process-flow` layout usually pairs with `flow_diagram`, `icon`, or `infographic`.
|
||||
- `comparison` layout often works with `icon`, `chart`, or `infographic`.
|
||||
- `timeline` layout often works with `icon`, `chart`, or shape-based milestone markers.
|
||||
- `big-number` layout often works with `chart` or `infographic`, but only if it supports the metric.
|
||||
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, or `infographic`; if missing, use a large placeholder diagram or stylized panel.
|
||||
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
|
||||
- "Use a placeholder."
|
||||
- "Find another image."
|
||||
- "Leave blank if unavailable."
|
||||
- "Use generic decoration."
|
||||
|
||||
## Examples
|
||||
|
||||
Transformer Self-Attention page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "paper_figure",
|
||||
"purpose": "Explain token-to-token attention and why each output token mixes context.",
|
||||
"suggested_query": "Transformer self attention attention matrix diagram",
|
||||
"fallback_if_missing": "Draw a simplified attention matrix with token labels, colored weights, and arrows from input tokens to one highlighted output token."
|
||||
}
|
||||
```
|
||||
|
||||
System architecture page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show the runtime path from user prompt to plan, XML generation, Slides API creation, and fetch verification.",
|
||||
"suggested_query": "slides generation runtime architecture planner XML API verification",
|
||||
"fallback_if_missing": "Draw four grouped boxes connected left-to-right with arrows; put verification as a return arrow from Slides API to agent."
|
||||
}
|
||||
```
|
||||
|
||||
Business comparison page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "infographic",
|
||||
"purpose": "Make before/after differences scannable without dense bullet lists.",
|
||||
"suggested_query": "before after product workflow comparison infographic",
|
||||
"fallback_if_missing": "Use two side-by-side panels with matching icon circles and three parallel rows of concise labels."
|
||||
}
|
||||
```
|
||||
|
||||
## Plan To XML Contract
|
||||
|
||||
When generating XML:
|
||||
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
@@ -1,155 +0,0 @@
|
||||
# 设计规则
|
||||
|
||||
这份规则用于生成或大幅改写演示文稿时做布局、组件、视觉层级和 XML 决策。它不是审美评论清单,而是生成前必须落到坐标、字号、颜色、组件和校验动作里的约束。
|
||||
|
||||
默认画布按 `960 x 540` 规划。模板可以覆盖具体坐标,但不能覆盖这些原则:页面要有清晰主视觉区域,文本要受密度约束,不同页型必须产生明显不同的几何结构。
|
||||
|
||||
## 生成前先定的事
|
||||
|
||||
- 先定页面角色:封面、章节页、观点页、证据页、数据页、对比页、流程页、时间线页、结论页。
|
||||
- 先定一个主锚点:标题、关键数字、图表、截图、结论句或对比差异。主锚点必须是页面最大、最靠前或最高对比的区域。
|
||||
- 先定结构令牌:外边距、标题区、主体区、栏宽、模块间距、组件内边距、圆角、线条粗细、色彩令牌和字体角色。
|
||||
- 先定文本密度,再写文本。不要先塞满内容,再靠缩字号抢救。
|
||||
- 先定组件类型。只有当内容确实是同级可比对象时才用卡片;只有当顺序有意义时才用流程或时间线。
|
||||
|
||||
## 核心规则
|
||||
|
||||
- `layout_type` 必须改变几何:不同页型的元素位置、区域大小、对齐方式和视觉节奏都要明显不同。
|
||||
- `visual_focus` 必须成为最大或最高对比区域。它可以是图片、图表、指标、引语、表格或形状占位视觉。
|
||||
- `text_density` 用来限制可见文本量:
|
||||
- `low`:标题加一句短陈述,或 1-3 个标签。
|
||||
- `medium`:标题加 2-4 条短要点,或若干带标签的信息区。
|
||||
- `high`:使用表格、分栏、分组标签或注释。不要用一个长 bullet 框承载所有信息。
|
||||
- 不要把整份演示文稿做成“标题加 bullet”。当页面数不少于 4 页且内容允许时,至少使用 4 种不同布局结构。
|
||||
- 优先使用少量大对象,不要堆很多小文本框。
|
||||
- 每页只允许 1 个主锚点。次级锚点必须解释、证明或补充主锚点,不能与它争抢注意力。
|
||||
|
||||
## 布局与间距
|
||||
|
||||
- 常规内容页使用 `60-80` px 外边距,除非有意使用全出血图片或封面式处理。
|
||||
- 常规内容页标题区通常为 `y=36..90`,主体内容通常从 `y>=110` 开始。标题文本框要足够宽,避免意外换行。
|
||||
- 非背景内容应尽量位于 `y=500` 以上,页脚除外。
|
||||
- 优先把主体拆成 2-3 个大区域,而不是很多碎片。常见结构包括:左文右图、左图右文、上标题下模块、指标锚点加证据区。
|
||||
- 同级模块必须共享 x/y、宽高、内边距和槽位顺序。不同尺寸只在差异本身就是信息时使用。
|
||||
- 相关元素之间的距离应小于不相关元素之间的距离;主锚点周围留更大呼吸空间,次要信息可以更紧凑但必须对齐。
|
||||
- 间距令牌要显式:`margin`、`titleGap`、`moduleGap`、`innerPadding`。重复模块用令牌生成坐标,不要手工逐个摆放。
|
||||
|
||||
## 背景与视觉母题一致性
|
||||
|
||||
- 为普通内容页选择一个默认背景,并精确复用。避免多个近似但不一致的背景色,除非它明确表示章节变化。
|
||||
- 封面、强调页、结论页可以使用深色、图片主导或高对比背景,但仍必须共享整套文稿的主色、视觉母题、边缘处理、字体或几何语言。
|
||||
- 如果封面使用左右或分区构图,背景或版式中必须看得出分区;为文字区和视觉区保留各自空间。
|
||||
- 复用少量视觉装置:侧边栏、卡片圆角、节点样式、线条粗细、图标容器或页脚处理。
|
||||
- 背景和视觉母题形状应先于内容元素插入,避免遮住文字、图片或图示。
|
||||
|
||||
## 视觉层级
|
||||
|
||||
- 用位置、大小、字重、留白和颜色共同建立层级,不要只靠颜色。
|
||||
- 至少定义 3 个清晰文本层级:标题/主张、模块标题或标签、正文/注释。复杂页可增加指标和来源角色。
|
||||
- 指标、结论或关键差异如果是页面主旨,必须放在专用区域中,不能埋在段落、图例或小卡片里。
|
||||
- 颜色只用于确认已经由尺寸和位置建立的层级。到处使用强调色会让页面失去重点。
|
||||
- 装饰不能成为第一视觉停点;分割线、网格线、卡片边框应低对比,除非它们表示选择、风险或状态。
|
||||
|
||||
## 字体与文本框
|
||||
|
||||
把字体当作角色系统,而不是逐个文本框随机调样式。推荐先定义这些角色:`deckTitle`、`slideTitle`、`sectionLabel`、`moduleTitle`、`body`、`caption`、`metric`、`source`。
|
||||
|
||||
| 文本用途 | 常见字号 | 最小高度 |
|
||||
|----------|----------|----------|
|
||||
| 注释,1 行 | 10-12 | 18 |
|
||||
| 注释,2 行 | 10-12 | 30 |
|
||||
| 正文,1 行 | 13-16 | 24 |
|
||||
| 正文,2 行 | 13-16 | 40 |
|
||||
| 正文,2 行,加粗 | 15-18 | 48 |
|
||||
| 小标题,1 行 | 24-32 | 42 |
|
||||
| 大标题,2 行 | 34-44 | 110 |
|
||||
| 核心指标 | 64-110 | 110 |
|
||||
|
||||
补充规则:
|
||||
|
||||
- 同一角色在同一页和同一组组件中必须使用相同 `fontFamily`、`fontSize`、`fontWeight` 和颜色。
|
||||
- 加粗文本、中文文本、中英混排、较大行距,或包含多个段落的文本块,都需要增加高度。
|
||||
- 不要把较长中文句子或英文短语放进 `height=18` 或 `height=22` 的文本框。这类高度只适合短标签。
|
||||
- 不要通过把正文缩到不可读来解决拥挤。优先缩短文案、拆分模块、扩大文本框或换成表格/卡片/图示结构。
|
||||
- 页脚和来源说明通常只放一行短文本。如果需要更多内容,应放成页脚上方的正式注释块。
|
||||
- 底部结论条承载一行强调文本时至少 `40` px 高,承载两行时至少 `54` px 高。
|
||||
|
||||
## 色彩
|
||||
|
||||
- 从中性底色、高对比正文色、低对比分割色、一个强调色开始。只有在表达正负、风险、阶段、类别等语义时才增加语义色。
|
||||
- 普通内容页避免给每个模块不同填充色。模块填充用于分组,强调色用于标记重要项或当前项。
|
||||
- 图表配色应克制:大多数系列保持柔和,只高亮支持页面结论的系列、行、列或单元格。
|
||||
- 同一套演示文稿中强调色的含义要稳定。不要在一页表示推荐,下一页又表示风险。
|
||||
- 长正文不要放在高饱和背景上。深色或图片背景必须保证标题和正文对比度。
|
||||
|
||||
## 页型规则
|
||||
|
||||
把这些高频页型当作几何承诺,而不是名称。
|
||||
|
||||
| `layout_type` | 几何承诺 | 文本限制 |
|
||||
|---------------|----------|----------|
|
||||
| `title-cover` | 一个主导性标题块,可配全出血背景、侧边大图、强调带或分区视觉区域。 | 只用 `low`:标题加副标题/背景句,不放 bullets。 |
|
||||
| `section-opener` | 大章节编号、标签或标题成为主锚点,其他内容只做定位。 | 只放章节名、短引导句和必要元信息。 |
|
||||
| `image-left-text-right` | 左侧视觉区占约 `35-45%` 宽度;密集截图或论文图可放大到 `50-65%`;右侧文本通常从 `x=420` 开始。 | 一个主标题加最多 4 条要点,或 2-3 张解读卡片/标注。 |
|
||||
| `image-right-text-left` | 左侧文本区通常从 `x=60..90` 开始,宽 `400..460`;右侧视觉区占约 `35-45%` 宽度,并与主文本块对齐。 | 一个核心判断加 2-3 条支撑点;标注要短且结构平行。 |
|
||||
| `two-column` | 主区域拆成两个均衡栏,例如 `x=60,width=400` 和 `x=500,width=400`;每栏都需要自己的标题或视觉锚点。 | `medium`:每栏 2-3 条短内容。`high`:使用分组行或小表格。 |
|
||||
| `big-number` | 最大对象必须留给指标:字号常为 `64-110`,区域至少 `300 x 120`。 | `low` 或 `medium`;数字周围只放紧凑标签、图例或小卡片。 |
|
||||
| `comparison` | 使用两个或三个对齐的面板、列、表格或小倍图;用一个明确线索突出推荐项或关键差异。 | 使用平行表述;避免长短不一的 bullet 列表。 |
|
||||
| `cards` | 2-6 个同级模块共享宽高、内边距、标题位置和槽位顺序。 | 每张卡片只承载一个短观点,不放段落。 |
|
||||
| `process` | 重复节点加连接线,形成单一路径;连接线在节点和标签下方。 | 步骤标签比描述更突出,描述短且平行。 |
|
||||
| `timeline` | 里程碑沿时间轴排列;时间差重要时按时间比例,否则按序列均分。 | 日期/阶段标签优先,说明文字短。 |
|
||||
| `data` | 结论区与证据区分离,指标或洞察先于图表细节。 | 图表标题写洞察,不只写指标名。 |
|
||||
| `conclusion` | 使用一个主导性的结论句或行动号召,最多搭配 3 个下一步卡片、检查项或负责人/日期标签。 | 保持易记,不要堆总结。 |
|
||||
|
||||
## 组件规则
|
||||
|
||||
- **封面/章节页**:只保留标题组、主视觉和少量元信息。不要把封面做成执行摘要、仪表盘或多卡片汇总。
|
||||
- **指标组件**:包含 value、unit、label、explanation、optional delta。数值是最大文本,单位和限定词靠近数值但更小。
|
||||
- **数据图表**:先给图表/表格留足边界,再放标注。优先用直接标签减少视线往返;网格线和坐标轴低对比。
|
||||
- **对比组件**:被比较对象必须共享槽位顺序,例如 label、metric、visual、description、emphasis marker。只高亮一个推荐项、变化项或风险项。
|
||||
- **卡片组件**:卡片只用于真实同级对象。标题长度和字号要平行,正文用一句话,推荐或选中卡片最多增加一个强调线索。
|
||||
- **流程/时间线**:节点从数组或索引生成位置,连接线先画、节点后画、标签最后画。不要让连接线穿过文字。
|
||||
- **截图/论文图/产品图**:真实素材必须足够大到可读;若过密,应裁切关键区域、做局部放大,或用原生形状重画核心信息。
|
||||
|
||||
## 截图与真实素材页面
|
||||
|
||||
- 根据页面角色决定放置方式,不按固定页码套模板。方法概览、证据页、对比页和失败分析页通常更适合放真实图片。
|
||||
- 只有在图片以幻灯片尺寸展示仍可读时,才直接使用真实素材。如果图过密,应裁切关键区域、做局部放大,或用原生形状重画核心信息。
|
||||
- 截图或真实图片通常应成为视觉焦点。不要把它缩成装饰性缩略图,同时在周围堆大量文字。
|
||||
- 配少量解释性标注,告诉观众应该看哪里。
|
||||
- 使用外部素材或论文图时,始终添加简短来源说明。
|
||||
|
||||
## XML / Slides 生成规则
|
||||
|
||||
- 先定义画布、边距、标题区、主体区、栏宽、间距、颜色、字体角色和组件 token,再生成具体对象。
|
||||
- 每个文本框必须先有语义角色,再分配坐标、字号、字重和颜色。
|
||||
- 所有形状、文本、图片、图表都使用显式 `x/y/width/height`,不要依赖默认位置或自适应猜测。
|
||||
- 重复组件必须从同一数据数组和同一几何函数生成,避免手写出微小不一致。
|
||||
- z-order 顺序通常是:背景、分区形状、连接线、图片/图表/卡片、文本、标注/强调层。
|
||||
- 颜色通过 `base`、`text`、`muted`、`accent`、`positive`、`negative`、`risk` 等 token 引用,不要在每个对象里临时硬编码。
|
||||
- 生成后必须检查重叠、裁切、异常换行、标题意外换行、底部溢出、来源说明缺失和主锚点不清。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- 页面没有主锚点,所有内容都像同级说明。
|
||||
- 把视觉稿变成标题加长 bullet,或者把研究材料原文直接塞进文本框。
|
||||
- 用很多颜色和边框填满空白,却没有表达语义。
|
||||
- 同级卡片尺寸、标题位置、槽位顺序不一致。
|
||||
- 数据页只有图表没有结论,或结论离证据太远。
|
||||
- 流程线穿过标签,时间线里程碑太多且描述过长。
|
||||
- 字号层级过平,标题、标签、正文看起来差不多大。
|
||||
- 为了装下文字持续缩字号,而不是删减、拆分或换结构。
|
||||
|
||||
## 证据来源
|
||||
|
||||
这些规则来自典型ppt案例的主题化提炼。核心跨文稿模式包括:
|
||||
|
||||
- 顶部标题先锚定页面,再组织主体内容。
|
||||
- 左右文本/视觉分区是最稳定的解释结构之一。
|
||||
- 结构化文本页使用重复对齐模块,而不是自由散落文本框。
|
||||
- 数据页通常用数字锚点配合图表或表格表达结论。
|
||||
- 字体尺度分离标题、标签和正文。
|
||||
- 中性底色配有限强调色比多色装饰更可靠。
|
||||
- 封面和章节页使用更少文本对象和更强视觉框架。
|
||||
- 多个同级对象应规范化为卡片或同构模块。
|
||||
- 流程和时间线通过重复节点加克制连接线表达顺序。
|
||||
@@ -1,266 +0,0 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
# slides (v1)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|-------------------------------------|----------|-----------------|
|
||||
| 新建 PPT | 先确定叙事、页序和视觉策略,再按复杂度选择一步或两步创建 | `design-rules.md`、`slides +create` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain slides
|
||||
```
|
||||
|
||||
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
|
||||
|
||||
**执行规则**:
|
||||
|
||||
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
|
||||
## 执行前必做
|
||||
|
||||
> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
|
||||
|
||||
高频只读:
|
||||
|
||||
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
|
||||
- [design-rules.md](references/design-rules.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
|
||||
|
||||
开始写 XML 前,先确定 deck 级视觉策略:
|
||||
|
||||
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
|
||||
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重,1-2 个辅助色承担结构和分区,1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
- **双栏结构**:左文右图或左图右文,视觉区域占 35-45% 宽度。
|
||||
- **图标行**:图标在色块或圆形底中,右侧是短标题和一句解释。
|
||||
- **2x2 / 2x3 网格**:适合能力、模块、风险、行动项,每格内容保持同等层级。
|
||||
- **半出血视觉**:图片或抽象形状占据左/右半屏,文字覆盖或贴边排布。
|
||||
- **大数字卡片**:关键指标用 60-72pt 数字,下面配 10-14pt 标签。
|
||||
- **对比列**:before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。
|
||||
- **时间线/流程图**:步骤用节点和箭头表达,流程方向必须一眼可见。
|
||||
|
||||
字体和间距建议:
|
||||
|
||||
- 标题 36-44pt,关键结论可更大;正文 14-18pt;注释 10-12pt。
|
||||
- 正文默认左对齐;只在封面、结尾或大号数字场景中使用居中。
|
||||
- 页面边距至少 40px;内容块之间保持 24-40px 间距,并在同一 deck 内保持一致。
|
||||
- 卡片内边距要真实留出空间,不要让文字贴边;对齐 shape 和文字时要考虑文本框 padding。
|
||||
|
||||
常见错误必须避免:
|
||||
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须用 XML-native 形状、图表、表格或 whiteboard 生成兜底视觉。
|
||||
- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 design-rules.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认
|
||||
- 生成结构化大纲供用户确认
|
||||
- 新建 / 大幅改写必须先明确 deck 目标、受众、页序、视觉系统和每页关键消息
|
||||
- 每页确定 `key_message`、`layout_type`、`visual_focus`、`text_density`;素材需求只作为设计意图
|
||||
|
||||
Step 3: 按已确认大纲生成 XML → 创建
|
||||
- 逐页生成 XML:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 XML-native 形状、图表、表格或 whiteboard 生成兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart、whiteboard 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
# 插到指定页之前:before_slide_id 必须在 --data body 里,与 slide 同级
|
||||
# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide ...>...</slide>' --arg before 'TARGET_SLIDE_ID' \
|
||||
'{slide:{content:$content}, before_slide_id:$before}')"
|
||||
```
|
||||
|
||||
> 渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
|
||||
### 大纲模板
|
||||
|
||||
生成大纲时使用以下格式,交给用户确认:
|
||||
|
||||
```text
|
||||
[PPT 标题] — [定位描述],面向 [目标受众]
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
3. [页面主题]:[要点描述]
|
||||
...
|
||||
N. 结尾页:[结尾文案]
|
||||
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### URL 格式与 Token
|
||||
|
||||
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|
||||
|----------|------|-----------|----------|
|
||||
| `/slides/` | `https://example.larkoffice.com/slides/xxxxxxxxxxxxx` | `xml_presentation_id` | URL 路径中的 token 直接作为 `xml_presentation_id` 使用 |
|
||||
| `/wiki/` | `https://example.larkoffice.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
|
||||
|
||||
> `+replace-slide` 和 `+media-upload` shortcut 会自动解析以上两种 URL;直接调用原生 API 时仍需手动解析 wiki 链接。
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先查询 wiki 节点,确认 `node.obj_type == "slides"`,再用 `node.obj_token` 作为真实 presentation ID。
|
||||
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
Shortcut `+replace-slide` 和 `+media-upload` 会自动解析 `/wiki/` URL;手动调用 `xml_presentations.*` / `xml_presentation.slide.*` 时才需要自己做这一步。
|
||||
|
||||
### 资源关系
|
||||
|
||||
```text
|
||||
Wiki Space (知识空间)
|
||||
└── Wiki Node (知识库节点, obj_type: slides)
|
||||
└── obj_token → xml_presentation_id
|
||||
|
||||
Slides (演示文稿)
|
||||
├── xml_presentation_id (演示文稿唯一标识)
|
||||
├── revision_id (版本号)
|
||||
└── Slide (幻灯片页面)
|
||||
└── slide_id (页面唯一标识)
|
||||
```
|
||||
|
||||
## Shortcuts 与 API
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,先确定 deck 目标、受众、页序、视觉系统和每页关键消息;不要从用户提示直接跳到 XML
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
@@ -1,77 +0,0 @@
|
||||
# PPT Template Rewrite Principles
|
||||
|
||||
本页只约束“用户指定 PPT 模板、底稿、已有 PPTX/PDF/Slides,并要求基于它二次创作”的场景。核心原则:模板不是风格参考,而是必须沿用的编辑底稿。
|
||||
|
||||
## Import First
|
||||
|
||||
用户指定 PPT 模板时,先使用 `lark-drive` 技能把模板导入成 Lark Slides。后续写入目标是导入后的 Slides,不是新建一个脱离模板的 deck,也不是先在本地重画 PPTX 再导入。
|
||||
|
||||
导入后必须回读 Slides 内容,理解每页的真实版式、字体、层级、图片、图表、shape、表格和文本容器。回读结果是模板二创的事实来源。
|
||||
|
||||
## Read Before Editing
|
||||
|
||||
编辑任何 PPT 页面前,必须先阅读该页面。
|
||||
|
||||
如果当前上下文中没有该页内容,必须重新读取页面;这里的“当前上下文”不包含 System Prompt。不能只凭记忆、文件名、缩略图印象或模板整体风格判断来编辑具体页面。
|
||||
|
||||
阅读页面时至少判断:
|
||||
|
||||
- 该页原本承担的角色,例如封面、章节页、目录、流程、对比、数据、总结。
|
||||
- 该页的主要版式结构,例如图文关系、箭头、时间线、节点、表格、图表、左右对照、背景图或产品图。
|
||||
- 哪些文本框、shape 标签、表格单元格或图表标签承载内容。
|
||||
- 原页面的字体、字号、颜色、对齐、层级和留白关系。
|
||||
|
||||
## Edit The Imported Slides Directly
|
||||
|
||||
理解页面后,直接在导入后的 Slides 上编辑。允许的操作包括:
|
||||
|
||||
- 填写、替换、凝练或删除文字。
|
||||
- 替换或补充图片。
|
||||
- 更新图表、表格、数字标签或节点标签里的内容。
|
||||
- 按需复制、删除或重排模板页。
|
||||
- 在源页面没有合适承载位置时,做局部、小范围新增元素。
|
||||
|
||||
新增元素只能补足内容缺口,不能成为新的主版式。页面主体仍应由模板原有版式承载。
|
||||
|
||||
## Preserve Design
|
||||
|
||||
模板二创必须严格沿用原版式和字体,只改内容,不做设计。
|
||||
|
||||
默认保留:
|
||||
|
||||
- 页面布局、视觉层级、留白和对齐关系。
|
||||
- 原字体、字号体系、颜色、文本框位置和 shape 顺序。
|
||||
- 背景图、图片、logo、图表、表格、装饰形状、线条、图标和页面结构。
|
||||
- 模板中不同页型之间的差异。
|
||||
|
||||
不要把模板页改造成统一的通用卡片、白板、标题栏、三栏、2x2 卡片或大面积遮罩。不要把模板当作背景图后另起一套设计系统。
|
||||
|
||||
## Content Only
|
||||
|
||||
内容必须优先进入原页面已有的文本框、shape 标签、节点、表格单元格、图表标签或注释容器。
|
||||
|
||||
如果原容器空间不足,优先:
|
||||
|
||||
- 凝练文字。
|
||||
- 降低字号但保持原字体体系。
|
||||
- 拆分到页面已有的邻近容器。
|
||||
- 使用模板已有的注释、标签或补充说明区域。
|
||||
- 复制同页或同模板中的原生容器样式做局部补充。
|
||||
|
||||
不要为了容纳长文案而重画页面主体结构。不要用新增大卡片遮住原图表、箭头、图片、背景或关键 shape。
|
||||
|
||||
## Readback And Tune
|
||||
|
||||
完成编辑后必须回读结果,并逐页微调。
|
||||
|
||||
回读时重点检查:
|
||||
|
||||
- 文字是否溢出、截断、压线或超出容器。
|
||||
- 文本是否遮挡图片、图表、shape、箭头、节点或其他文字。
|
||||
- shape 顺序是否导致内容被覆盖或遮住。
|
||||
- 新内容是否仍然落在模板原有版式中,而不是覆盖模板结构。
|
||||
- 字体、字号、颜色、对齐和层级是否仍贴近原页。
|
||||
|
||||
发现文字溢出时,优先凝练文字或缩减字号。发现遮挡时,调整 shape 顺序、局部位置或复用原有空白区域解决。只有在这些方法都不能满足内容表达时,才做局部新增或删除。
|
||||
|
||||
模板二创的完成标准不是“生成了一套看起来统一的新 PPT”,而是“原模板的版式、字体和视觉结构仍清晰存在,内容已经被准确替换,并且回读后没有溢出和遮挡”。
|
||||
@@ -237,4 +237,4 @@ lark-cli slides +replace-slide --as user \
|
||||
- [xml_presentation.slide get](lark-slides-xml-presentation-slide-get.md) — 读原页拿 `block_id` / `revision_id`
|
||||
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
|
||||
- [+media-upload](lark-slides-media-upload.md) — 上传图片拿 `file_token`
|
||||
- [lark-slides-edit-workflows.md](lark-slides-block-replace-workflows) — 读-改-写闭环 + 决策树
|
||||
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环 + 决策树
|
||||
|
||||
@@ -67,7 +67,7 @@ lark-cli slides xml_presentation.slide create --as user --params '<json_params>'
|
||||
</slide>
|
||||
```
|
||||
|
||||
详细格式请参考 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
详细格式请参考 [xml-format-guide.md](xml-format-guide.md) 和 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
## 使用示例
|
||||
|
||||
@@ -216,4 +216,5 @@ done
|
||||
- [slides +create](lark-slides-create.md) - 创建空白 PPT
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
|
||||
- [xml_presentation.slide delete](lark-slides-xml-presentation-slide-delete.md) - 删除幻灯片页面
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - XML 格式和 Schema 唯一 Markdown 入口
|
||||
- [xml-format-guide.md](xml-format-guide.md) - XML 格式详细规范
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - Schema 快速参考
|
||||
|
||||
@@ -107,4 +107,4 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
- [slides +replace-slide](lark-slides-replace-slide.md) — 块级替换 shortcut(推荐)
|
||||
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读整个 PPT
|
||||
- [lark-slides-edit-workflows.md](lark-slides-block-replace-workflows) — 读-改-写闭环
|
||||
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环
|
||||
|
||||
@@ -184,4 +184,4 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
|
||||
- [slides +replace-slide](lark-slides-replace-slide.md) — 块级替换 shortcut(推荐,自动注入 id)
|
||||
- [xml_presentation.slide get](lark-slides-xml-presentation-slide-get.md) — 读原页拿 block short ID
|
||||
- [slides +media-upload](lark-slides-media-upload.md) — 上传图片拿 file_token
|
||||
- [lark-slides-edit-workflows.md](lark-slides-block-replace-workflows) — 读-改-写闭环 + 决策树
|
||||
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环 + 决策树
|
||||
|
||||
216
skills/lark-slides/references/planning-layer.md
Normal file
216
skills/lark-slides/references/planning-layer.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Planning Layer
|
||||
|
||||
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构,仍然需要规划层。
|
||||
|
||||
## Required Flow
|
||||
|
||||
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
|
||||
2. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`。
|
||||
3. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`。
|
||||
4. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
5. 读取 `xml-schema-quick-ref.md`、`visual-planning.md` 和 `asset-planning.md`。
|
||||
6. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
7. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
|
||||
## Plan Path
|
||||
|
||||
Use a separate plan directory per deck or task so multiple presentations in the same workspace cannot overwrite each other.
|
||||
|
||||
Recommended IDs:
|
||||
|
||||
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
|
||||
- Existing PPT rewrite: the `xml_presentation_id`.
|
||||
- Ambiguous or untitled task: short task slug plus date/time.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not reuse `.lark-slides/plan/slide_plan.json` as a shared path.
|
||||
- Create the directory before writing the file.
|
||||
- Reuse the same plan path for XML generation and post-create verification for that deck.
|
||||
|
||||
## Artifact Lifecycle
|
||||
|
||||
`.lark-slides/` is local agent state. It supports recovery, iteration, and later edits, but it should not be treated as source code or committed by default.
|
||||
|
||||
Keep:
|
||||
|
||||
- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
|
||||
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.
|
||||
|
||||
Clean or avoid keeping:
|
||||
|
||||
- Transient XML payloads after successful creation and verification. Prefer `/tmp` for throwaway XML, or delete generated XML files after success.
|
||||
- Stale XML drafts that no longer match the current presentation state.
|
||||
|
||||
Exception:
|
||||
|
||||
- If creation fails or partially succeeds, keep the relevant XML/debug payloads until recovery is complete. Record `xml_presentation_id` first, then fetch current state before retrying.
|
||||
|
||||
## JSON Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
|
||||
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
|
||||
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
|
||||
"visual_system": {
|
||||
"background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
|
||||
"motif": "A reusable left accent bar and consistent card/header treatments.",
|
||||
"color_roles": {
|
||||
"primary": "Used for the dominant structural motif and about 60-70% of visual weight.",
|
||||
"secondary": "Used for grouped regions, comparison panels, or supporting categories.",
|
||||
"accent": "Used only for key numbers, conclusions, or focus markers."
|
||||
}
|
||||
},
|
||||
"typography_constraints": {
|
||||
"title_max_lines": 2,
|
||||
"body_max_lines_per_box": 2,
|
||||
"footer_max_lines": 1,
|
||||
"long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
|
||||
},
|
||||
"verification_plan": {
|
||||
"check_background_consistency": true,
|
||||
"check_text_fit": true,
|
||||
"check_visual_focus": true,
|
||||
"check_asset_rendering": true
|
||||
},
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"title": "Proposal Title",
|
||||
"key_message": "The initiative is ready for a focused pilot.",
|
||||
"layout_type": "title-cover",
|
||||
"visual_focus": "Large title area with one concise supporting statement.",
|
||||
"asset_need": {
|
||||
"asset_type": "logo",
|
||||
"purpose": "Signal product or team identity on the opening page.",
|
||||
"suggested_query": "product logo",
|
||||
"fallback_if_missing": "Use a small text badge and abstract shape motif instead of a real logo."
|
||||
},
|
||||
"text_density": "low",
|
||||
"speaker_intent": "Frame the decision and establish the deck's point of view."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Required Fields
|
||||
|
||||
Top-level fields:
|
||||
|
||||
- `presentation_goal`: what the whole deck is trying to achieve.
|
||||
- `audience`: target readers or listeners and their assumed background.
|
||||
- `theme_style`: visual tone, palette direction, and professional style.
|
||||
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
|
||||
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
|
||||
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
|
||||
- `slides`: ordered page plans.
|
||||
|
||||
Each slide must include:
|
||||
|
||||
- `page`: 1-based page number.
|
||||
- `title`: slide title.
|
||||
- `key_message`: the one idea this page must land.
|
||||
- `layout_type`: planned page structure.
|
||||
- `visual_focus`: dominant visual object or region.
|
||||
- `asset_need`: planning-only structured asset metadata; no search, download, or upload required. Follow `asset-planning.md`.
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
|
||||
- `title-cover`
|
||||
- `section-divider`
|
||||
- `two-column`
|
||||
- `image-left-text-right`
|
||||
- `image-right-text-left`
|
||||
- `big-number`
|
||||
- `timeline`
|
||||
- `comparison`
|
||||
- `architecture-diagram`
|
||||
- `process-flow`
|
||||
- `quote-highlight`
|
||||
- `conclusion`
|
||||
|
||||
The value must affect XML geometry, not just appear as a label. For example, `timeline` should create a horizontal or vertical sequence, `comparison` should create distinct side-by-side regions, and `big-number` should reserve dominant space for a large metric.
|
||||
|
||||
## Text Density Rules
|
||||
|
||||
- `low`: title plus 1 short statement, or 1-3 very short labels.
|
||||
- `medium`: title plus 2-4 concise bullets or labeled regions.
|
||||
- `high`: allowed only when the user needs detail; use tables, columns, or grouped regions instead of a long bullet list.
|
||||
|
||||
Do not let all pages become title + bullet slides. For decks of 4 or more pages, aim for at least 4 different `layout_type` values when the content allows it.
|
||||
|
||||
Text density must be realistic for the planned geometry. If a page needs long titles, bilingual labels, paper figure captions, legal disclaimers, or dense technical wording, record how the text will be shortened, split, or moved to speaker notes. Do not rely on small font sizes or tight boxes to make text fit.
|
||||
|
||||
## Visual System Planning
|
||||
|
||||
Before generating XML, define a visual system that can survive the whole deck:
|
||||
|
||||
- `background_strategy`: specify the default background for normal content pages, and which page roles may intentionally differ. Do not let pages drift through near-identical but inconsistent background colors.
|
||||
- `motif`: choose one or two reusable structural devices, such as a side bar, header rail, numbered node, card treatment, diagram lane, or section band. The motif should appear consistently enough that pages feel related.
|
||||
- `color_roles`: assign primary, secondary, and accent roles. The same color must not mean unrelated things across pages.
|
||||
- `cover_content_relationship`: if the cover uses a different dark or image-led treatment, state how it connects to content pages through shared colors, motifs, or geometry.
|
||||
- `closing_relationship`: if the closing page mirrors the cover, state that explicitly so it looks intentional rather than like a new theme.
|
||||
|
||||
These are planning constraints, not decoration notes. They must affect coordinates, background fills, shape styles, and text placement in generated XML.
|
||||
|
||||
## Iterative Deck State
|
||||
|
||||
When continuing an existing deck, update the same plan path rather than creating a new disconnected plan. Keep the plan aligned with what has actually been created.
|
||||
|
||||
Recommended optional fields for long-running work:
|
||||
|
||||
- `deck_status`: current slide count, target slide count if known, and last verified revision or timestamp.
|
||||
- `created_slides`: page number, slide id when known, and the page role.
|
||||
- `assets_used`: source, local path when applicable, uploaded token when known, and which page uses it.
|
||||
- `open_issues`: known layout, text fit, asset, or consistency risks that still need correction.
|
||||
|
||||
Do not hard-code a page number just because a previous deck used that pattern. Plan by page role and evidence need, such as "method overview pages should use a figure when the source has a readable figure" instead of binding screenshots, charts, or diagrams to a fixed page index. The plan should describe decision rules, not a rigid template sequence.
|
||||
|
||||
## Asset Planning
|
||||
|
||||
`asset_need` is metadata. It can describe a desired figure, diagram, chart, icon, logo, screenshot, or fallback shape-based visual, but it must not require web search, local download, or media upload.
|
||||
|
||||
Use an object for one planned asset, an array for multiple real needs, or `asset_type: "none"` when no asset is useful. Each planned asset must include:
|
||||
|
||||
- `asset_type`: one of `paper_figure`, `architecture_diagram`, `icon`, `logo`, `chart`, `infographic`, `screenshot`, `flow_diagram`, or `none`.
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
|
||||
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
|
||||
Good examples:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
|
||||
|
||||
## XML Generation Contract
|
||||
|
||||
Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
|
||||
- `key_message` determines the headline, dominant claim, or main takeaway.
|
||||
- `layout_type` determines the coordinate structure and element types. Use `visual-planning.md` for concrete layout rules.
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
|
||||
- Page count matches the plan.
|
||||
- Every page has the planned title and key message represented.
|
||||
- At least several pages have visibly different XML layout structures.
|
||||
- Planned `visual_focus` appears as a dominant visual region or object.
|
||||
- Asset planning is proportional to the deck topic and length: technical, research, product, and analytical decks should include meaningful planned visuals where they clarify the story, and each planned asset has a visible fallback if no real asset was used.
|
||||
- `text_density` is reflected in the amount of visible text.
|
||||
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
|
||||
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
|
||||
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
|
||||
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Slides Template Workflow
|
||||
|
||||
当用户提到"模板""套用模板""参考某种主题/风格/版式",或需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),使用本文。
|
||||
|
||||
## 核心规则
|
||||
|
||||
- 必须先用 `scripts/template_tool.py search` 做模板检索,默认给出 2-3 个最匹配模板候选供用户选择。
|
||||
- 锁定模板后用 `summarize` 获取主题和布局摘要。
|
||||
- 只有需要具体布局骨架时才用 `extract` 裁切目标页型 XML。
|
||||
- 不要直接读取完整模板 XML。
|
||||
- 不要照搬模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
`scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
模板细则见 [template-catalog.md](template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
|
||||
```
|
||||
|
||||
## 生成流程
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 design-rules.md
|
||||
- 按本文检索模板并给出候选
|
||||
|
||||
Step 2: 生成大纲 -> 用户确认
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 新建 / 大幅改写必须先明确 deck 目标、受众、页序、视觉系统和每页关键消息
|
||||
- 模板只提供风格和局部布局骨架,不要照搬无关占位内容
|
||||
|
||||
Step 3: 按已确认大纲生成 XML -> 创建
|
||||
- 逐页生成 XML:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按 SKILL.md 的"创建方式选择"判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 -> 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
## 大纲格式
|
||||
|
||||
生成大纲时使用以下格式,交给用户确认:
|
||||
|
||||
```text
|
||||
[PPT 标题] - [定位描述],面向 [目标受众]
|
||||
|
||||
模板:[未使用模板 / <category>/<template>.xml(推荐原因)]
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
3. [页面主题]:[要点描述]
|
||||
...
|
||||
N. 结尾页:[结尾文案]
|
||||
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# Validation Checklist
|
||||
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证,如果只创建空白ppt则不用验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。
|
||||
|
||||
小型已有页编辑也要做对应范围的验证:至少读取被改页面或全文 XML,确认目标元素已更新且未破坏周边结构。
|
||||
|
||||
@@ -46,7 +46,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
- 实际页数必须等于用户要求或已确认大纲的页数。
|
||||
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。
|
||||
- 如果创建过程部分失败,先记录已创建的 `xml_presentation_id`,再回读确认哪些页已写入。
|
||||
- 每页都应包含 `<data>`,且 `<data>` 内至少有一个非背景主体元素。
|
||||
- 封面、章节页、总结页可以文字较少,但不能只有空背景。
|
||||
@@ -54,7 +54,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|
||||
## Expected Elements
|
||||
|
||||
按已确认大纲和用户要求逐页核对:
|
||||
按 `slide_plan.json` 和用户要求逐页核对:
|
||||
|
||||
- 标题或主结论存在,并能对应 `key_message`。
|
||||
- `layout_type` 对应的主要结构已生成。
|
||||
|
||||
254
skills/lark-slides/references/visual-planning.md
Normal file
254
skills/lark-slides/references/visual-planning.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Visual Planning
|
||||
|
||||
新建演示文稿或大幅改写页面时,在 `slide_plan.json` 完成后、生成 XML 前读取本文件。目标是让 `layout_type`、`visual_focus`、`text_density` 变成实际页面几何,而不是只写在 plan 里。
|
||||
|
||||
默认画布按 `960 x 540` 规划。已有页面回读 XML 可以影响具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `layout_type` must change geometry: element positions, region sizes, alignment, and visual rhythm must differ across page types.
|
||||
- `visual_focus` determines the largest or highest-contrast region. It can be an image, diagram, metric, quote, table, or shape-based placeholder.
|
||||
- `text_density` caps visible text:
|
||||
- `low`: title plus one short statement, or 1-3 labels.
|
||||
- `medium`: title plus 2-4 concise bullets or labeled regions.
|
||||
- `high`: use a table, columns, grouped labels, or annotations. Do not use one long bullet box.
|
||||
- Do not create a deck where every content page is title plus bullets. For 4 or more pages, use at least 4 different layout structures when the content allows.
|
||||
- Keep generous margins. Use `60-80` px outer margins on standard content pages unless a full-bleed image or cover treatment is intentional.
|
||||
- Reserve vertical space for titles. A typical content title area is `y=36..90`; main content should usually start at `y>=110`.
|
||||
- Avoid crowding the bottom edge. Keep non-background content above `y=500` unless it is a footer.
|
||||
- Prefer fewer, larger objects over many small text boxes.
|
||||
- Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change.
|
||||
- Treat text fit as a layout constraint, not a cleanup step. If a text box is too small for the intended line count, shorten the text, split it, or allocate more space before creating XML.
|
||||
|
||||
## Background And Motif Consistency
|
||||
|
||||
Decks can vary page backgrounds, but variation must be intentional and legible:
|
||||
|
||||
- Pick one default background for ordinary content pages and reuse it exactly. Avoid near-identical drift such as several slightly different off-white values unless it encodes a clear section change.
|
||||
- Cover, section divider, emphasis, and conclusion pages may use a dark, image-led, or high-contrast background. They must still share the deck's primary color, motif, edge treatment, typography, or geometry.
|
||||
- If a cover uses a split composition, make the split visible in the background or layout. For example, reserve a darker text region and a related but distinct visual region instead of placing all elements on one flat field.
|
||||
- Reuse a small number of visual devices: side bar, card radius, node style, line weight, icon container, or footer treatment. Do not introduce a new decorative language on each page.
|
||||
- Insert background and motif shapes before content elements so they do not cover text, images, or diagrams.
|
||||
|
||||
## Text Fit Guardrails
|
||||
|
||||
Use these as conservative minimums on a 960 x 540 canvas. Increase height when using bold text, Chinese text, mixed Chinese/English, or line spacing above default.
|
||||
|
||||
| Text use | Typical font size | Minimum height |
|
||||
|----------|-------------------|----------------|
|
||||
| Caption, 1 line | 10-12 | 18 |
|
||||
| Caption, 2 lines | 10-12 | 30 |
|
||||
| Body, 1 line | 13-16 | 24 |
|
||||
| Body, 2 lines | 13-16 | 40 |
|
||||
| Body, 2 lines, bold | 15-18 | 48 |
|
||||
| Headline, 1 line | 24-32 | 42 |
|
||||
| Title, 2 lines | 34-44 | 110 |
|
||||
|
||||
Additional rules:
|
||||
|
||||
- Do not put long Chinese sentences or long English phrases into `height=18` or `height=22` boxes. Those heights are for short labels only.
|
||||
- Footer/source text should usually be one short line. If it needs more, make it a real caption block above the footer area.
|
||||
- Bottom conclusion bars should be at least `40` px tall for one emphasized line and at least `54` px tall for two lines.
|
||||
- Diagram labels should be short enough to fit the shape. Prefer two short lines over one cramped long line.
|
||||
- When a text block has more than one `<p>`, size the box for multiple lines explicitly. Do not assume the renderer will auto-expand.
|
||||
- If a line contains mixed Chinese and English, budget more width than either language alone; mixed text wraps less predictably.
|
||||
|
||||
## Layout Types
|
||||
|
||||
### `title-cover`
|
||||
|
||||
Purpose: introduce the deck's point of view.
|
||||
|
||||
Geometry:
|
||||
- Use one dominant title block, usually `x=70..120`, `y=150..250`, `width=700..820`.
|
||||
- Add one subtitle or context line, not a bullet list.
|
||||
- Optional visual focus can be a full-bleed background, large side image, accent band, or abstract shape motif.
|
||||
- If the cover has a right-side diagram, screenshot, or motif cluster, use a split layout: keep the title/subtitle region within the left or central text region, and reserve a separate visual region so labels and connectors do not cross the title.
|
||||
- For split covers, make the background reinforce the composition, such as a darker text side and a related visual panel. Avoid one flat field where title and diagram compete for attention.
|
||||
- Keep source metadata to one short line where possible. If it wraps, shorten author lists or move details to notes.
|
||||
- The main title should be controlled, normally one or two lines. Do not let it occupy both the text region and the visual region.
|
||||
|
||||
Text:
|
||||
- `low` only unless the user explicitly asks for detail.
|
||||
|
||||
### `section-divider`
|
||||
|
||||
Purpose: reset rhythm and mark a new chapter.
|
||||
|
||||
Geometry:
|
||||
- Use a large section number, chapter label, or single centered claim.
|
||||
- Keep the page sparse. A divider is not a content page.
|
||||
- Visual focus can be one oversized number, a vertical accent bar, or a full-width band.
|
||||
|
||||
Text:
|
||||
- Title plus one phrase. No bullets.
|
||||
|
||||
### `two-column`
|
||||
|
||||
Purpose: compare two related ideas or pair explanation with evidence.
|
||||
|
||||
Geometry:
|
||||
- Split main region into two balanced columns, for example left `x=60,width=400`, right `x=500,width=400`.
|
||||
- Each column needs its own heading or visual anchor.
|
||||
- Do not place one full-width bullet box under a normal title; that is not a two-column layout.
|
||||
|
||||
Text:
|
||||
- `medium`: 2-3 short items per column.
|
||||
- `high`: use grouped rows or mini table structure inside columns.
|
||||
|
||||
### `image-left-text-right`
|
||||
|
||||
Purpose: let a visual establish context, with text explaining implication.
|
||||
|
||||
Geometry:
|
||||
- Left visual region should occupy roughly `35-45%` of slide width, often full height or tall crop.
|
||||
- Right text region starts around `x=420` and should have a strong headline plus short support.
|
||||
- If no real image is available, create a shape-based placeholder visual that matches `asset_need`.
|
||||
- For dense screenshots, paper figures, or product captures with small labels, allocate a larger visual region when possible: often `50-65%` of slide width or at least `320` px height.
|
||||
- Place screenshots in a deliberate frame or panel, and leave enough margin so axes, captions, and edge labels are not cropped by the slide boundary.
|
||||
|
||||
Text:
|
||||
- Keep right-side text short. Avoid more than 4 bullets.
|
||||
- For screenshot explanation pages, prefer 2-3 interpretation cards or callouts instead of a paragraph block.
|
||||
|
||||
### `image-right-text-left`
|
||||
|
||||
Purpose: lead with a message, then reinforce it with a visual.
|
||||
|
||||
Geometry:
|
||||
- Left text region starts around `x=60..90`, width `400..460`.
|
||||
- Right visual region occupies roughly `35-45%` of slide width.
|
||||
- Align the image or placeholder with the main text block, not only with the title.
|
||||
- For dense screenshots, paper figures, or product captures with small labels, increase the visual region and reduce text. A readable image is more valuable than a fully populated text column.
|
||||
|
||||
Text:
|
||||
- Use one main claim and 2-3 supporting points.
|
||||
- Keep callouts parallel and short. If a callout needs more than two lines, split it into a separate note or a new slide.
|
||||
|
||||
### `big-number`
|
||||
|
||||
Purpose: make one metric or fact memorable.
|
||||
|
||||
Geometry:
|
||||
- Reserve the largest object for the metric: font size often `64-110`, region at least `300 x 120`.
|
||||
- Pair the number with one explanation and optional 2-3 small supporting labels.
|
||||
- Do not bury the number in a bullet list or small card.
|
||||
|
||||
Text:
|
||||
- `low` or `medium`. If detail is needed, add small annotations around the metric.
|
||||
- Supporting labels must not compete with the number. Use compact labels, legends, or mini-cards rather than long explanatory bars.
|
||||
|
||||
### `timeline`
|
||||
|
||||
Purpose: show sequence, roadmap, history, or phases.
|
||||
|
||||
Geometry:
|
||||
- Create a horizontal or vertical spine with 3-6 milestones.
|
||||
- Each milestone should have a dot/card/date label connected by a line or arrow.
|
||||
- Title is separate from the sequence. The sequence is the visual focus.
|
||||
|
||||
Text:
|
||||
- Each milestone gets a short label and optional one-line explanation.
|
||||
- Do not use paragraph-length milestone descriptions.
|
||||
|
||||
### `comparison`
|
||||
|
||||
Purpose: make a choice, before/after, old/new, or option tradeoff clear.
|
||||
|
||||
Geometry:
|
||||
- Use two or three distinct panels, columns, or a table-like structure.
|
||||
- Headings must be visually aligned so differences are easy to scan.
|
||||
- Use color, border, icon, or label treatment to highlight the preferred option or key difference.
|
||||
|
||||
Text:
|
||||
- Use parallel wording across columns.
|
||||
- Avoid uneven long bullet lists that destroy comparability.
|
||||
|
||||
### `architecture-diagram`
|
||||
|
||||
Purpose: explain components, dependencies, or system flow.
|
||||
|
||||
Implementation: prefer `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` only as fallback.
|
||||
|
||||
Geometry:
|
||||
- Main visual area should be a diagram, not prose.
|
||||
- Use grouped boxes, lanes, arrows or lines, and short labels.
|
||||
- Keep diagram labels concise. Put explanation in notes or a small side caption if needed.
|
||||
|
||||
Text:
|
||||
- Prefer labels of 1-5 words.
|
||||
- Use no more than one short explanatory text block.
|
||||
- If a node label needs two lines, size the node and the text box for two lines. Do not let labels overlap connectors.
|
||||
|
||||
### `process-flow`
|
||||
|
||||
Purpose: show operational steps, workflow, or cause-effect path.
|
||||
|
||||
Implementation: prefer `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` only as fallback.
|
||||
|
||||
Geometry:
|
||||
- Use numbered steps connected by arrows or lines.
|
||||
- 3-5 steps is ideal for one slide. If there are more, group them into phases.
|
||||
- The flow direction must be visually obvious.
|
||||
|
||||
Text:
|
||||
- Each step gets a verb-led label and one short descriptor at most.
|
||||
- Step labels should be parallel in length and grammar. If one step needs a long explanation, move the explanation to a side note or speaker notes.
|
||||
|
||||
### `quote-highlight`
|
||||
|
||||
Purpose: emphasize a customer voice, principle, thesis, or decision statement.
|
||||
|
||||
Geometry:
|
||||
- Quote or claim is the dominant text object.
|
||||
- Use large type, generous whitespace, and optional attribution or context badge.
|
||||
- Do not combine a quote-highlight page with a normal bullet section.
|
||||
|
||||
Text:
|
||||
- One quote or statement, plus optional attribution. No bullets.
|
||||
|
||||
### `conclusion`
|
||||
|
||||
Purpose: close with decision, recommendation, or next action.
|
||||
|
||||
Geometry:
|
||||
- Use one dominant closing statement or call to action.
|
||||
- Add up to 3 next-step cards, checklist items, or owner/date labels.
|
||||
- Visual focus should be the recommendation or action, not decorative filler.
|
||||
|
||||
Text:
|
||||
- Keep the final page easy to remember. Avoid recap overload.
|
||||
- Conclusion pages may mirror the cover background, but must clearly reuse the deck's motif or color roles so the ending feels intentional.
|
||||
|
||||
## Screenshot And Paper Figure Pages
|
||||
|
||||
When a page uses a real screenshot, chart, paper figure, or product capture:
|
||||
|
||||
- Choose screenshot placement based on page role, not a fixed slide number. Method overview, evidence, comparison, and failure-analysis pages are common candidates; title, agenda, and conclusion pages usually are not.
|
||||
- Use the real asset only when it is readable at slide size. If the figure is too dense, crop to the relevant region, create a zoomed detail, or redraw the core message with native shapes.
|
||||
- A screenshot should normally be the visual focus. Do not shrink it into a decorative thumbnail while surrounding it with dense text.
|
||||
- Pair the image with a small number of interpretive annotations that tell the audience what to notice.
|
||||
- Always include a short source caption when using external or paper-derived visuals.
|
||||
- Verify the final XML contains a supported image token or creation-time local placeholder, not an unsupported external URL.
|
||||
|
||||
## Plan To XML Checklist
|
||||
|
||||
Before creating XML for each page, answer these checks:
|
||||
|
||||
1. Which region is the visual focus, and is it the largest or most prominent object?
|
||||
2. Does the XML geometry match the `layout_type` description above?
|
||||
3. Does `text_density` limit the number of paragraphs, bullets, labels, and text boxes?
|
||||
4. Would this page still be recognizable if the `layout_type` label were removed from the plan?
|
||||
5. Across the deck, do multiple pages use genuinely different structures?
|
||||
6. Does the background follow the planned deck strategy, and are any deviations intentional?
|
||||
7. Are all text boxes large enough for their intended font size and line count?
|
||||
8. If the page uses a screenshot or paper figure, is it large enough to read and accompanied by concise interpretation?
|
||||
|
||||
After fetching the created presentation, verify:
|
||||
|
||||
- Use `timeline`, `comparison`, and `architecture-diagram` only when the content calls for them; do not force irrelevant page types.
|
||||
- Any planned `timeline`, `comparison`, or `architecture-diagram` page uses the matching sequence, side-by-side comparison, or component-and-connection structure.
|
||||
- Pages are not crowded and do not rely on long bullet boxes.
|
||||
- Main claim, supporting detail, and visual focus have clear hierarchy.
|
||||
- Static XML inspection should include text-fit risk: very short text boxes containing long text, multi-paragraph boxes with insufficient height, footer text that may wrap, and labels placed directly over connectors.
|
||||
- Background and motif consistency should be checked across pages, not only within one slide.
|
||||
369
skills/lark-slides/references/xml-format-guide.md
Normal file
369
skills/lark-slides/references/xml-format-guide.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# XML 格式指南
|
||||
|
||||
本文档基于 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 整理,说明飞书 Slides XML Schema(SML 2.0)的核心结构和常用写法。
|
||||
|
||||
## 基本结构
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>演示文稿标题</title>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgb(245, 245, 245)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>主标题</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content textType="body">
|
||||
<p>这是演讲者备注。</p>
|
||||
</content>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
```
|
||||
|
||||
## 根元素
|
||||
|
||||
### `<presentation>`
|
||||
|
||||
协议标准写法应带命名空间 `http://www.larkoffice.com/sml/2.0`;当前服务端实现可能兼容不带 `xmlns` 的输入,但不作为协议保证。
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `width` | positiveInteger | 是 | 演示文稿宽度,如 `960` |
|
||||
| `height` | positiveInteger | 是 | 演示文稿高度,如 `540` |
|
||||
| `id` | string | 否 | 演示文稿标识 |
|
||||
|
||||
**子元素:**
|
||||
|
||||
| 元素 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `<title>` | 否 | 演示文稿标题 |
|
||||
| `<theme>` | 否 | 全局主题 |
|
||||
| `<slide>` | 是 | 幻灯片页面,至少 1 页,最多 100 页 |
|
||||
|
||||
## 主题
|
||||
|
||||
### `<theme>`
|
||||
|
||||
`<theme>` 当前包含两部分:
|
||||
|
||||
- `<background>`:演示文稿级背景填充
|
||||
- `<textStyles>`:主题文本样式集合
|
||||
|
||||
`<textStyles>` 下可选子元素:
|
||||
|
||||
- `<title>`
|
||||
- `<headline>`
|
||||
- `<sub-headline>`
|
||||
- `<body>`
|
||||
- `<caption>`
|
||||
|
||||
这些元素定义的是主题默认样式,不是页面结构。常用属性:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `fontFamily` | 字体 |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontColor` | 字体颜色 |
|
||||
|
||||
## 幻灯片元素
|
||||
|
||||
### `<slide>`
|
||||
|
||||
单张幻灯片的结构比较严格。
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | string | 否 | 幻灯片标识 |
|
||||
|
||||
**直接子元素只有:**
|
||||
|
||||
| 元素 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `<style>` | 否 | 页面样式 |
|
||||
| `<data>` | 否 | 页面元素容器 |
|
||||
| `<note>` | 否 | 演讲者备注 |
|
||||
|
||||
这意味着 `<title>`、`<headline>`、`<body>`、`<caption>` 不能直接放在 `<slide>` 下。
|
||||
|
||||
## 文本内容模型
|
||||
|
||||
### `<content>`
|
||||
|
||||
实际页面文本通常通过 `<content>` 表达,常见位置有:
|
||||
|
||||
- `shape` 内部
|
||||
- `table/td` 内部
|
||||
- `note` 内部
|
||||
|
||||
**常用属性:**
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
|
||||
| `verticalAlign` | 垂直对齐 |
|
||||
| `textAlign` | 水平对齐 |
|
||||
| `lineSpacing` | 行间距 |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontFamily` | 字体 |
|
||||
| `color` | 字体颜色 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 内容级样式 |
|
||||
| `wrap` | 是否自动换行 |
|
||||
|
||||
**可包含的子元素:**
|
||||
|
||||
- `<p>`
|
||||
- `<ul>`
|
||||
- `<ol>`
|
||||
|
||||
### `<p>`
|
||||
|
||||
`<p>` 是段落元素,可混排纯文本和内联标签:
|
||||
|
||||
- `<br/>`
|
||||
- `<strong>`
|
||||
- `<em>`
|
||||
- `<u>`
|
||||
- `<span>`
|
||||
- `<del>`
|
||||
- `<a>`
|
||||
- `<shadow>`
|
||||
- `<outline>`
|
||||
|
||||
示例:
|
||||
|
||||
```xml
|
||||
<content textType="body" textAlign="left">
|
||||
<p>普通文本 <strong>加粗</strong> <em>斜体</em> <a href="https://example.com">链接</a></p>
|
||||
<ul>
|
||||
<li><p>列表项 1</p></li>
|
||||
<li><p>列表项 2</p></li>
|
||||
</ul>
|
||||
</content>
|
||||
```
|
||||
|
||||
## 常用页面元素
|
||||
|
||||
所有页面元素都放在 `<data>` 中。
|
||||
|
||||
### `<shape>`
|
||||
|
||||
`shape` 可表示普通形状,也可表示文本框。文本框推荐使用 `type="text"`。
|
||||
|
||||
```xml
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>主标题</p>
|
||||
</content>
|
||||
</shape>
|
||||
```
|
||||
|
||||
```xml
|
||||
<shape type="rect" topLeftX="700" topLeftY="120" width="180" height="120">
|
||||
<fill>
|
||||
<fillColor color="rgba(100, 149, 237, 0.25)"/>
|
||||
</fill>
|
||||
<border color="rgb(100, 149, 237)" width="2"/>
|
||||
</shape>
|
||||
```
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | 是 | 形状类型,`text` 表示文本框 |
|
||||
| `topLeftX` | 是 | 左上角 X 坐标 |
|
||||
| `topLeftY` | 是 | 左上角 Y 坐标 |
|
||||
| `width` | 是 | 宽度 |
|
||||
| `height` | 是 | 高度 |
|
||||
| `rotation` | 否 | 旋转角度 |
|
||||
| `flipX` / `flipY` | 否 | 翻转 |
|
||||
| `alpha` | 否 | 透明度 |
|
||||
|
||||
**可选子元素:**
|
||||
|
||||
- `<fill>`
|
||||
- `<border>`
|
||||
- `<reflection>`
|
||||
- `<shadow>`
|
||||
- `<content>`
|
||||
|
||||
### `<line>`
|
||||
|
||||
```xml
|
||||
<line startX="100" startY="200" endX="420" endY="200">
|
||||
<border color="rgb(43, 47, 54)" width="2"/>
|
||||
</line>
|
||||
```
|
||||
|
||||
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`。
|
||||
|
||||
### `<img>`
|
||||
|
||||
```xml
|
||||
<img src="file_token_or_url" topLeftX="100" topLeftY="220" width="320" height="180"/>
|
||||
```
|
||||
|
||||
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`。
|
||||
|
||||
`src` 只接受两种值:
|
||||
|
||||
| `src` 形式 | 说明 |
|
||||
|---|---|
|
||||
| `file_token`(如 `boxcnXXXXXXXXXXXXXXXXXXXXXX`) | 通过 `slides +media-upload` 上传后返回的 token |
|
||||
| `@<本地路径>`(如 `@./assets/chart.png`) | **仅在 `slides +create --slides` 中可用**:CLI 会自动上传该文件并替换为 file_token |
|
||||
|
||||
> **禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,`src="https://..."` 在 PPT 里通常显示破图。要用网图必须先 `curl`/下载到 CWD 内,再走上传流程拿 `file_token`。
|
||||
|
||||
本地图片的两种姿势:
|
||||
|
||||
- **新建带图 PPT**:`+create --slides` 里直接写 `src="@./pic.png"`,CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token
|
||||
- **给已有 PPT 加带图新页**:先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token,再用 token 写进 `xml_presentation.slide create` 的 XML
|
||||
|
||||
### `<icon>`
|
||||
|
||||
```xml
|
||||
<icon iconType="iconpark/Base/setting.svg" topLeftX="440" topLeftY="220" width="32" height="32"/>
|
||||
```
|
||||
|
||||
### `<table>`
|
||||
|
||||
表格结构为:
|
||||
|
||||
- `<table>`
|
||||
- `<colgroup>` / `<tr>`
|
||||
- `<tr>` 内为 `<td>`
|
||||
- `<td>` 内可放 `<content>`
|
||||
|
||||
### `<chart>`
|
||||
|
||||
图表元素必须至少包含:
|
||||
|
||||
- `<chartPlotArea>`
|
||||
- `<chartData>`
|
||||
|
||||
同时还可以包含:
|
||||
|
||||
- `<chartTitle>`
|
||||
- `<chartSubTitle>`
|
||||
- `<chartStyle>`
|
||||
- `<chartLegend>`
|
||||
- `<chartTooltip>`
|
||||
|
||||
如果要写图表 XML,建议直接以 XSD 为准,不要自行发明更简化的 chart DSL。
|
||||
|
||||
## 样式元素
|
||||
|
||||
### `<fill>`
|
||||
|
||||
```xml
|
||||
<fill>
|
||||
<fillColor color="rgb(100, 149, 237)"/>
|
||||
</fill>
|
||||
```
|
||||
|
||||
### `<border>`
|
||||
|
||||
```xml
|
||||
<border color="rgb(0, 0, 0)" width="2" dashArray="solid"/>
|
||||
```
|
||||
|
||||
### 颜色格式
|
||||
|
||||
```xml
|
||||
<fillColor color="rgb(255, 0, 0)"/>
|
||||
<fillColor color="rgba(255, 0, 0, 0.5)"/>
|
||||
<fillColor color="linear-gradient(90deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
|
||||
<fillColor color="radial-gradient(circle at 50% 50%, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
|
||||
```
|
||||
|
||||
## 演讲者备注
|
||||
|
||||
### `<note>`
|
||||
|
||||
```xml
|
||||
<note>
|
||||
<content textType="body">
|
||||
<p>这是演讲者备注内容。</p>
|
||||
</content>
|
||||
</note>
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>季度报告</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
|
||||
<body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgb(245, 245, 245)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="72" width="760" height="100">
|
||||
<content textType="title">
|
||||
<p>2024 年第一季度报告</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape type="text" topLeftX="80" topLeftY="200" width="520" height="180">
|
||||
<content textType="body">
|
||||
<p>核心指标</p>
|
||||
<ul>
|
||||
<li><p>用户增长:+25%</p></li>
|
||||
<li><p>收入增长:+30%</p></li>
|
||||
<li><p>市场份额:15%</p></li>
|
||||
</ul>
|
||||
</content>
|
||||
</shape>
|
||||
<shape type="rect" topLeftX="660" topLeftY="180" width="180" height="140">
|
||||
<fill>
|
||||
<fillColor color="rgba(100, 149, 237, 0.25)"/>
|
||||
</fill>
|
||||
<border color="rgb(100, 149, 237)" width="2"/>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content textType="body">
|
||||
<p>讲到增长率时补充样本范围。</p>
|
||||
</content>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. 始终带上命名空间 `xmlns="http://www.larkoffice.com/sml/2.0"`
|
||||
2. 用 `shape type="text"` + `content` 表达页面文本
|
||||
3. 用 `topLeftX` / `topLeftY`、`startX` / `startY` 等 schema 中定义的属性名
|
||||
4. 优先使用 `rgb` / `rgba` 颜色格式
|
||||
5. 特殊字符按 XML 规则转义
|
||||
6. 标准 16:9 页面建议使用 `width="960"` 和 `height="540"`
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md)
|
||||
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
|
||||
- [examples.md](examples.md)
|
||||
- [slides_demo.xml](slides_demo.xml)
|
||||
@@ -8,8 +8,6 @@
|
||||
2. `<presentation>` 直接子元素只有 `<title>`、`<theme>`、`<slide>`
|
||||
3. `<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`
|
||||
4. 页面中的文本通常通过 `<content>` 表达,而不是把 `<title>`、`<body>` 直接挂在 `<slide>` 下
|
||||
5. 文本中的特殊字符必须按 XML 规则转义,例如 `&` 写成 `&`,`<` / `>` 写成 `<` / `>`
|
||||
6. 标准 16:9 页面建议使用 `width="960"` 和 `height="540"`
|
||||
|
||||
## 最小可用示例
|
||||
|
||||
@@ -38,8 +36,6 @@
|
||||
|
||||
**子元素:** `<title>?`, `<theme>?`, `<slide>+`
|
||||
|
||||
`<slide>` 至少 1 页,最多 100 页。
|
||||
|
||||
## slide 元素
|
||||
|
||||
| 属性 | 必需 | 说明 |
|
||||
@@ -58,19 +54,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<theme><textStyles>...</textStyles></theme>` 中,作为主题文本样式
|
||||
- `<content textType="...">` 中,作为内容的文本类型
|
||||
|
||||
`<theme>` 当前可包含:
|
||||
|
||||
- `<background>` - 演示文稿级背景填充
|
||||
- `<textStyles>` - 主题文本样式集合
|
||||
|
||||
主题文本样式常用属性:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `fontFamily` | 字体 |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontColor` | 字体颜色 |
|
||||
|
||||
`textStyles` 的 schema 默认值如下:
|
||||
|
||||
| textType | 默认字号 |
|
||||
@@ -88,14 +71,12 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
|
||||
| `verticalAlign` | 垂直对齐方式 |
|
||||
| `textAlign` | 文本对齐方式 |
|
||||
| `lineSpacing` | 行间距,schema 默认 `multiple:1.5` |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontFamily` | 字体 |
|
||||
| `color` | 字体颜色 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |
|
||||
| `wrap` | 是否自动换行 |
|
||||
|
||||
`<content>` 的子元素只能是:
|
||||
|
||||
@@ -103,8 +84,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<ul>`
|
||||
- `<ol>`
|
||||
|
||||
`<p>` 可混排纯文本和内联标签:`<br/>`、`<strong>`、`<em>`、`<u>`、`<span>`、`<del>`、`<a>`、`<shadow>`、`<outline>`。
|
||||
|
||||
### content 示例
|
||||
|
||||
```xml
|
||||
@@ -138,10 +117,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| `width` | 是 | 宽度 |
|
||||
| `height` | 是 | 高度 |
|
||||
| `rotation` | 否 | 旋转角度 |
|
||||
| `flipX` / `flipY` | 否 | 翻转 |
|
||||
| `alpha` | 否 | 透明度 |
|
||||
|
||||
可选子元素:`<fill>`、`<border>`、`<reflection>`、`<shadow>`、`<content>`。
|
||||
|
||||
### line
|
||||
|
||||
@@ -151,16 +126,12 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
</line>
|
||||
```
|
||||
|
||||
`line` 使用 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`。
|
||||
|
||||
### img
|
||||
|
||||
```xml
|
||||
<img src="file_token_or_url" topLeftX="80" topLeftY="120" width="320" height="180"/>
|
||||
```
|
||||
|
||||
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`。
|
||||
|
||||
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
|
||||
|
||||
> **注意**:`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。
|
||||
@@ -173,14 +144,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
|
||||
`iconType` 必须来自已验证的 IconPark 路径。需要语义图标时,先运行 `scripts/iconpark_tool.py search --query "<语义>"`,不要凭记忆拼路径。更多规则见 [iconpark.md](iconpark.md)。
|
||||
|
||||
### table
|
||||
|
||||
表格结构为 `<table>`,内部可包含 `<colgroup>` / `<tr>`,`<tr>` 内为 `<td>`,`<td>` 内可放 `<content>`。
|
||||
|
||||
### chart
|
||||
|
||||
图表元素必须至少包含 `<chartPlotArea>` 和 `<chartData>`;还可包含 `<chartTitle>`、`<chartSubTitle>`、`<chartStyle>`、`<chartLegend>`、`<chartTooltip>`。复杂 chart XML 以 XSD 为准,不要自行发明简化 DSL。
|
||||
|
||||
### whiteboard
|
||||
|
||||
```xml
|
||||
@@ -268,6 +231,13 @@ Mermaid 模式:内容用 `<![CDATA[...]]>` 包裹,避免 `[`、`>`、`-->`
|
||||
</note>
|
||||
```
|
||||
|
||||
## 详细参考
|
||||
|
||||
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
|
||||
- [xml-format-guide.md](xml-format-guide.md)
|
||||
- [examples.md](examples.md)
|
||||
- [slides_demo.xml](slides_demo.xml)
|
||||
|
||||
## Schema 版本信息
|
||||
|
||||
- **版本**: 2.0.0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Docs CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 8 leaf commands
|
||||
- Covered: 3
|
||||
- Coverage: 37.5%
|
||||
- Denominator: 11 leaf commands
|
||||
- Covered: 6
|
||||
- Coverage: 54.5%
|
||||
|
||||
## Summary
|
||||
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
|
||||
@@ -11,6 +11,8 @@
|
||||
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
|
||||
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
|
||||
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
|
||||
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
|
||||
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
|
||||
|
||||
@@ -20,6 +22,9 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
|
||||
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
|
||||
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
|
||||
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
|
||||
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
|
||||
|
||||
137
tests/cli_e2e/docs/docs_history_workflow_test.go
Normal file
137
tests/cli_e2e/docs/docs_history_workflow_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDocs_HistoryWorkflow(t *testing.T) {
|
||||
if os.Getenv("LARK_DOC_HISTORY_E2E") != "1" {
|
||||
t.Skip("set LARK_DOC_HISTORY_E2E=1 to run docs history live workflow")
|
||||
}
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-docs-history-folder-" + suffix
|
||||
docTitle := "lark-cli-e2e-docs-history-" + suffix
|
||||
originalMarker := "original history marker " + suffix
|
||||
updatedMarker := "updated history marker " + suffix
|
||||
const defaultAs = "user"
|
||||
|
||||
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
|
||||
docToken := createDocWithRetry(t, parentT, ctx, folderToken, docTitle, originalMarker, defaultAs)
|
||||
|
||||
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+update",
|
||||
"--doc", docToken,
|
||||
"--command", "overwrite",
|
||||
"--doc-format", "markdown",
|
||||
"--content", "# " + docTitle + "\n\n" + updatedMarker,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
updateResult.AssertExitCode(t, 0)
|
||||
updateResult.AssertStdoutStatus(t, true)
|
||||
|
||||
fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchUpdated.AssertExitCode(t, 0)
|
||||
fetchUpdated.AssertStdoutStatus(t, true)
|
||||
updatedContent := gjson.Get(fetchUpdated.Stdout, "data.document.content").String()
|
||||
assert.Contains(t, updatedContent, updatedMarker)
|
||||
currentRevision := gjson.Get(fetchUpdated.Stdout, "data.document.revision_id").Int()
|
||||
require.Greater(t, currentRevision, int64(0), "stdout:\n%s", fetchUpdated.Stdout)
|
||||
|
||||
var revertHistoryVersionID string
|
||||
require.Eventually(t, func() bool {
|
||||
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+history-list",
|
||||
"--doc", docToken,
|
||||
"--page-size", "20",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, listErr)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() {
|
||||
revisionID := entry.Get("revision_id").Int()
|
||||
historyVersionID := entry.Get("history_version_id").String()
|
||||
if revisionID > 0 && revisionID < currentRevision && historyVersionID != "" {
|
||||
revertHistoryVersionID = historyVersionID
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 45*time.Second, 3*time.Second, "history list did not expose a prior revision")
|
||||
require.NotEmpty(t, revertHistoryVersionID)
|
||||
|
||||
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+history-revert",
|
||||
"--doc", docToken,
|
||||
"--history-version-id", revertHistoryVersionID,
|
||||
"--wait-timeout-ms", "30000",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
revertResult.AssertExitCode(t, 0)
|
||||
revertResult.AssertStdoutStatus(t, true)
|
||||
|
||||
status := gjson.Get(revertResult.Stdout, "data.status").String()
|
||||
taskID := gjson.Get(revertResult.Stdout, "data.task_id").String()
|
||||
statusStdout := revertResult.Stdout
|
||||
if status == "running" {
|
||||
require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout)
|
||||
require.Eventually(t, func() bool {
|
||||
statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+history-revert-status",
|
||||
"--doc", docToken,
|
||||
"--task-id", taskID,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, statusErr)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
statusStdout = statusResult.Stdout
|
||||
status = gjson.Get(statusResult.Stdout, "data.status").String()
|
||||
return status != "" && status != "running"
|
||||
}, 60*time.Second, 5*time.Second, "history revert task did not finish")
|
||||
}
|
||||
require.Equal(t, "done", status, "status stdout:\n%s", statusStdout)
|
||||
|
||||
fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchReverted.AssertExitCode(t, 0)
|
||||
fetchReverted.AssertStdoutStatus(t, true)
|
||||
revertedContent := gjson.Get(fetchReverted.Stdout, "data.document.content").String()
|
||||
assert.Contains(t, revertedContent, originalMarker)
|
||||
assert.NotContains(t, revertedContent, updatedMarker)
|
||||
}
|
||||
@@ -26,7 +26,10 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantContains []string
|
||||
wantURL string
|
||||
wantParams map[string]any
|
||||
wantBody map[string]any
|
||||
wantExtraParam string
|
||||
wantRefLabel string
|
||||
}{
|
||||
@@ -37,7 +40,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--content", "<title>Dry Run</title><p>hello</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents",
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
|
||||
},
|
||||
{
|
||||
name: "create api-version v1 compatibility",
|
||||
@@ -47,7 +50,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--content", "<title>Dry Run</title><p>hello</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents",
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
@@ -56,7 +59,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch"},
|
||||
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
|
||||
},
|
||||
{
|
||||
@@ -68,7 +71,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--content", "<p>hello</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
},
|
||||
{
|
||||
name: "update reference-map",
|
||||
@@ -80,7 +83,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
wantRefLabel: "widget-ref-value",
|
||||
},
|
||||
{
|
||||
@@ -92,7 +95,50 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--block-id", "blkA,blkB,blkC",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
},
|
||||
{
|
||||
name: "history list",
|
||||
args: []string{
|
||||
"docs", "+history-list",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/histories",
|
||||
wantParams: map[string]any{
|
||||
"page_size": 5,
|
||||
"page_token": "page_token_1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "history revert",
|
||||
args: []string{
|
||||
"docs", "+history-revert",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert",
|
||||
wantBody: map[string]any{
|
||||
"history_version_id": "42",
|
||||
"wait_timeout_ms": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "history revert status",
|
||||
args: []string{
|
||||
"docs", "+history-revert-status",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--task-id", "task_1",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert_status",
|
||||
wantParams: map[string]any{
|
||||
"task_id": "task_1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,10 +152,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
for _, want := range []string{
|
||||
tt.wantURL,
|
||||
"docs_ai/v1",
|
||||
} {
|
||||
for _, want := range append(tt.wantContains, "docs_ai/v1") {
|
||||
if !strings.Contains(combined, want) {
|
||||
t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr)
|
||||
}
|
||||
@@ -120,6 +163,15 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
if strings.Contains(combined, "--api-version") {
|
||||
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
if tt.wantURL != "" {
|
||||
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
|
||||
}
|
||||
for key, want := range tt.wantParams {
|
||||
assertDryRunField(t, result.Stdout, "api.0.params."+key, want)
|
||||
}
|
||||
for key, want := range tt.wantBody {
|
||||
assertDryRunField(t, result.Stdout, "api.0.body."+key, want)
|
||||
}
|
||||
if tt.wantExtraParam != "" {
|
||||
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
|
||||
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
|
||||
@@ -132,6 +184,21 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertDryRunField(t *testing.T, stdout, path string, want any) {
|
||||
t.Helper()
|
||||
|
||||
got := gjson.Get(stdout, path)
|
||||
require.True(t, got.Exists(), "%s missing in stdout:\n%s", path, stdout)
|
||||
switch want := want.(type) {
|
||||
case int:
|
||||
require.Equal(t, int64(want), got.Int(), "%s in stdout:\n%s", path, stdout)
|
||||
case string:
|
||||
require.Equal(t, want, got.String(), "%s in stdout:\n%s", path, stdout)
|
||||
default:
|
||||
t.Fatalf("unsupported dry-run assertion type %T for %s", want, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
||||
// Fake creds are enough — dry-run short-circuits before any real API call.
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
|
||||
@@ -63,29 +63,5 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
|
||||
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"minutes", "+speaker-replace",
|
||||
"--minute-token", "obcnexampleminute",
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain speaker replace path, got: %s", output)
|
||||
assert.False(t, strings.Contains(output, "/transcript/speakerlist"), "+speaker-replace should never fetch speakerlist, got: %s", output)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user